Trending
How the Explore tab decides what's trending: the algorithm, why it looks the way it does, and the jobs that build it. Endpoints and response shapes are in Discovery & Search.
Goals
- Anyone can go viral. A new account with 12 followers whose post lands should be able to outrank a large account whose post doesn't. Follower count must not be the deciding factor.
- Fresh beats stale. A post from this morning that's taking off should show today, not tomorrow.
- No request computes anything. Ranking a week of engagement is expensive. It's done in the background on a schedule, and requests only read the stored result.
Where the ideas come from
The algorithm combines well-known techniques used by the large platforms:
| Platform technique | What PlayPalz uses |
|---|---|
| TikTok's test batch. Every new video is shown to a small audience, and it's promoted based on how that audience responds, not on the creator's follower count. | Discovery slots (every 4th position) give fresh posts from small or new accounts their first audience. Ranking uses engagement per view, not totals. |
| X/Twitter's weighted engagement. Replies count for far more than likes. | A comment counts 3× a reaction. |
| Instagram/TikTok dwell and watch time. Stopping on a post is a signal even without a tap. | An engaged view (≥ 3 s dwell or watch) counts 0.5. |
| Reddit/Hacker News "hot" gravity. Score is divided by a power of age. | ÷ (ageHours + 2)^1.5 |
| Bayesian averages (rating sites). A rate is pulled toward the global mean until there's enough data. | Engagement per view is smoothed with a prior of 20 views at the global rate. |
| Feed diversity rules. No single account fills the page. | At most 2 posts per author in the ranked list, and 1 per author in the discovery pool. |
Posts and clips
Inputs
For every public, published post from the last 7 days by an active author, the build counts:
| Signal | Source | Notes |
|---|---|---|
reactions | Reaction | |
comments | Comment | Same rule as countedCommentWhere: live, not hidden, and not under a hidden parent |
impressions | PostImpression | One per viewer per post |
engagedViews | PostImpression | viewDurationMs ≥ 3000 or videoWatchMs ≥ 3000 |
The author's own reactions, comments and views are excluded, so nobody can trend themselves. Posts must have visibility = "public". VIP, followers-only and private posts never appear.
Score
E = reactions + 3·comments + 0.5·engagedViews
rate = (E + 20·μ) / (impressions + 20)
score = rate · ln(2 + E) · clipBoost / (ageHours + 2)^1.5rateis how people respond per view. It doesn't depend on audience size, so a small creator's post that 40% of viewers react to beats a big creator's post that 2% react to.μis the global engagement per impression, measured by each daily build (trending:mean-rate). It's the prior: a post nobody has seen yet scores exactlyμ, and a post with 1 view and 1 like can't beat a post with 1,000 views and 300 likes.ln(2 + E)keeps reach relevant without letting it dominate. 10× the engagement gives roughly 1.5× the score, not 10×.clipBoostis 1.2 for clips, because Explore leads with video. It's a nudge, not an override.- Gravity 1.5 lets fresh posts overtake last week's hits within hours.
Discovery slots: how a new creator gets in
A post can't earn a high rate if nobody sees it. Discovery fixes that.
- Eligible: posts under 48 h old whose author has < 1,000 followers or an account < 30 days old.
- Pool: eligible posts are ranked by
rate(an unseen post sits atμ, so it's included), capped at 1 per author, and the top 50 are kept. - Weighted shuffle: the pool is shuffled each hour (Efraimidis–Spirakis, weighted by
rate). Better-responding posts tend to come first, but every hour tests a different mix. - Interleave: every 4th position in the Explore feed and the clips list is taken from the pool. If a discovery post already ranks above its slot, it keeps that place and the slot goes to the next one in the pool.
A post that does well in its slot collects impressions and engagement. Its rate rises, and by the next hourly run it may rank in the main list on its own merit. A post that doesn't do well drops back to the prior and leaves the pool after 48 h.
Creators (PlayPalz and coaches)
Ranked by momentum, not size:
momentum = newFollowers7d / √(totalFollowers + 50) + 0.1 · (Σ E of their posts this week)50 new followers counts for far more on an account with 100 followers than on one with 100,000. Creators who are active this week (new followers or post engagement) are ranked first. If a quiet week leaves the list short, it's padded with the most-followed creators. Both lists include active users only. PlayPalz are userType = "playpal" without the coach badge, and coaches have the coach badge.
Games and searches
- Games: ranked by the number of distinct creators who posted public clips of the game this week, with ties broken by clip count. One prolific account can't push a game up on its own. The response's
postCountis the clip count. - Searches: queries are trimmed and lower-cased, then ranked by the number of distinct people who searched them, not raw rows. Repeating a search doesn't inflate it.
Jobs
Both jobs run in the API process on the BullMQ trending queue (apps/api/src/jobs/trending.ts). Every replica upserts the same job schedulers, so each build runs once whatever the replica count. Set TRENDING_WORKER=false on a replica to opt it out.
Every run is logged and recorded like any other API job: watch them on the admin Jobs page (which can also run either build now) or the Grafana API jobs row. See API → Background jobs.
| Job | Schedule (UTC) | Work |
|---|---|---|
trending-daily | 0 5 * * *, after the 04:00 content sweep and the 04:30 reconciliation | Scores every post from the last 7 days and keeps the top 1,000 as trending:candidates, with μ. Rebuilds the creator, game and search lists. Then runs the rising build. |
trending-rising | 10 * * * * (hourly) | Re-scores the stored candidates at the current time (their counts are from the daily build, but their age is current). Merges in fresh counts for posts from the last 48 h, the only part that queries the database. Builds the discovery pool, then writes the Explore feed and clip list. |
On startup, if trending:built-at is missing (a fresh environment, or keys that expired), the API queues a one-off daily build with a fixed job id, so several replicas starting at once queue only one.
The build logic is in apps/api/src/services/trendingBuild.ts. The scoring functions are pure and unit-tested, in apps/api/src/services/trendingScore.ts. All tuning knobs are constants at the top of that file.
Running a build by hand
pnpm --filter @playpals/api trending:buildThis runs the daily build (which includes the rising build) once. Use it locally and after db:seed:explore. Otherwise seeded data doesn't appear in trending until the next scheduled run or an API restart with empty keys.
Storage
| Key | Contents | Written by |
|---|---|---|
trending:posts | Explore feed ranking, [{ id, userId }], up to 500 | rising |
trending:clips | Clip cards (TrendingClipResult[]), up to 40 | rising |
trending:playpalz / trending:coaches | TrendingUserResult[], up to 40, isFollowing: false | daily |
trending:games | TrendingGameResult[], up to 20 | daily |
trending:searches | string[], up to 8 | daily |
trending:candidates | Scored week candidates with their counts, up to 1,000 | daily |
trending:mean-rate | μ | daily |
trending:built-at / trending:rising-at | ISO timestamps of the last runs | daily / rising |
Every key has a 3-day TTL as a safety net only. The jobs overwrite the keys long before it expires, and an expired built-at triggers the startup bootstrap.
Read path
apps/api/src/services/trending.ts only reads. Per request it:
- removes authors and creators the viewer has blocked (lists are stored longer than they're shown, so the viewer still gets a full list);
- marks creators the viewer follows;
- re-reads posts fresh, so deletes and edits show immediately. Clip comment counts are adjusted for comments by blocked users.
A missing key reads as an empty list. A request never runs a build.
Paging through /trending/posts uses an offset into trending:posts. The list changes only when the hourly build runs, so a scroll that spans a rebuild can shift by a few items.
Tuning
All knobs are in trendingScore.ts:
| Constant | Default | Raise it to… |
|---|---|---|
COMMENT_WEIGHT | 3 | favour conversation over taps |
ENGAGED_VIEW_WEIGHT / ENGAGED_VIEW_MS | 0.5 / 3000 | reward dwell and watch time more, or require longer dwell |
PRIOR_IMPRESSIONS | 20 | require more views before a post's own rate dominates |
GRAVITY | 1.5 | make trending turn over faster |
CLIP_BOOST | 1.2 | lean Explore further toward video |
MAX_PER_AUTHOR | 2 | allow one account more of the list |
DISCOVERY_EVERY | 4 | lower it to give new creators more slots |
DISCOVERY_MAX_FOLLOWERS / DISCOVERY_MAX_ACCOUNT_AGE_DAYS | 1,000 / 30 | widen who counts as "new" |
DISCOVERY_MAX_POST_AGE_HOURS | 48 | keep posts in the discovery pool longer |
MOMENTUM_FOLLOWER_DAMPING | 50 | weaken how much small accounts benefit in creator lists |
Known limits
- There's no moderation gate.
Post.moderationStatusexists but nothing writes it, so trending can't filter on it. Reported content stays eligible until it's removed. - There's no share signal. Share-to-DM isn't counted yet. It would be a strong signal (TikTok weights shares highly).
- Engagement isn't filtered for quality. Reactions from brand-new or suspicious accounts count the same as any other. Excluding self-activity is the only integrity rule so far.
- Daily counts for older posts are up to a day old. Posts older than 48 h are re-scored hourly for age, but their counts only refresh at the daily build.
