Visual walkthrough · based on xai-org/x-algorithm

How the X “For You” algorithm works

Every time you open the For You feed, X has to pick ~50 posts out of hundreds of millions. It does this in two stages — cheaply narrow the field, then expensively rank what's left — all driven by a Grok-based transformer that learns what you like from your engagement history. This page walks through the whole machine, one box at a time.

Section 1

1 The big picture: two stages

You can't run a heavyweight ML model on hundreds of millions of posts per request — that would be far too slow. So the system splits the job in two: a fast, approximate retrieval stage that finds a few hundred promising posts, followed by a slower, accurate ranking stage that orders them precisely.

For You feed request
“Give user #123 a fresh feed”
Home Mixer — the orchestration layer
A Rust service that runs every step below in order
Stage 1 — Retrieval
Hundreds of millions → ~hundreds
fast, approximate
Stage 2 — Ranking
Hundreds → ranked top K
slow, accurate
Ranked feed response
The posts you actually scroll through
Three codebases do the work. home-mixer/ is the Rust conductor that orchestrates everything; phoenix/ holds the JAX/Haiku ML models (the “brain”); thunder/ is an in-memory store of recent posts from accounts you follow.
Section 2

2 Where candidates come from

Before anything is ranked, the feed gathers a pool of candidate posts from two very different sources.

IN-NETWORK  Thunder

Recent posts from accounts you already follow. Thunder keeps these in memory — it consumes post create/delete events off Kafka and keeps per-user stores trimmed to a recent retention window.

  • No ML needed — just “who do you follow?”
  • Sub-millisecond lookups, no database hit
  • Always fresh, always relevant-ish

OUT-OF-NETWORK  Phoenix Retrieval

Posts from people you don't follow, surfaced by an ML model that searches a global corpus for content similar to what you've engaged with.

  • This is the “discovery” part of the feed
  • Powered by the two-tower model in Section 3
  • Lets you see great posts from strangers

Both pools are merged into one list and flow into the same ranking, filtering, and scoring steps from here on.

Section 3 · Stage 1

3 Retrieval: the two-tower model

How do you find relevant posts among hundreds of millions fast? You turn both the user and every post into vectors in the same space, then the most relevant posts are simply the ones whose vector points in the same direction as yours.

User Tower

Your engagement history
posts you liked / replied to / reposted + their authors
Grok transformer encodes the sequence
Mean-pool → L2-normalize
user vector  [D]

Candidate Tower

Each post in the corpus
post embedding + author embedding
Small MLP projection (SiLU)
L2-normalize
post vectors  [N, D]
↓   both sides live in the same space   ↓
similarity = user vector · post vectorsᵀ
Because both vectors are normalized, the dot product is cosine similarity. Take the top‑K highest scores → your retrieved posts.
Why “two towers”? The candidate tower doesn't depend on the user, so every post's vector can be computed ahead of time and indexed. At request time you only compute one new vector (yours) and do a fast nearest-neighbor lookup. That's what makes searching millions of posts per request feasible.

Source: phoenix/recsys_retrieval_model.py — see build_user_representation, CandidateTower, and _retrieve_top_k.

Section 4 · Stage 2

4 Ranking: the Phoenix transformer

Retrieval gave us a few hundred candidates but only a rough similarity score. Now a bigger Grok-based transformer looks at each candidate carefully and predicts how likely you are to take each action on it.

The input is one long sequence

The model packs three things into a single token sequence and runs the transformer over all of it at once:

User
[1] token
who you are
History
[S] tokens
posts you engaged with + authors + actions + age
Candidates
[C] tokens
the posts we want to score

Each token isn't a word — it's a post (or author, or action) turned into a vector via hash-based embeddings. The transformer reads your history to understand your taste, then evaluates each candidate against it.

The output: a probability per action

Rather than one vague “relevance” number, the model's final layer predicts a separate probability for every kind of engagement — run through a sigmoid into 0–1 probabilities:

P(favorite) P(reply) P(repost) P(quote) P(click) P(profile click) P(video view) P(photo expand) P(share) P(dwell) P(follow author) P(not interested) P(block) P(mute) P(report)

Note the red ones — the model also predicts how likely you are to dislike a post. Section 6 shows how those pull a score down.

Source: phoenix/recsys_model.py (PhoenixModel) and phoenix/runners.py.

Section 5

5 The clever trick: candidate isolation

Here's the most important design detail in the ranker. Inside the transformer, attention controls which tokens can “look at” which. The ranker uses a special mask so that candidates can see you and your history, but never each other.

Each row is a token doing the looking (a query); each column is a token being looked at (a key). Blue = allowed, gray = blocked, gold = a candidate attending to itself.

can attend blocked candidate → itself only

What the mask says

  • User + History attend among themselves (causal) — the model builds a picture of your taste.
  • Candidates attend to the user and the whole history — “how do I fit this person?”
  • Candidates are blocked from attending to each other — each one is scored in its own bubble.

Why it matters

A post's score depends only on you, never on which other posts happen to share its batch. That makes scores consistent (the same post always scores the same) and cacheable, and it lets candidates be scored in parallel without interfering.

Source: phoenix/grok.pymake_recsys_attn_mask.

Section 6

6 Turning predictions into one score

The model handed us ~15 probabilities per post. To sort the feed we need a single number. The Weighted Scorer multiplies each probability by a tunable weight and adds them up.

Final score  =  Σ ( weighti × P(actioni) )

Positive actions get positive weights and push a post up; negative actions get negative weights and push it down. Illustrative weights:

After the weighted score, two more scorers adjust it:

Author Diversity Scorer

The 2nd, 3rd, … post from the same author gets multiplied by a decaying factor, so no single account can flood your feed.

mult(pos) = (1−floor) × decaypos + floor

OON Scorer

Out-of-network posts get multiplied by a tunable factor — the dial that balances familiar in-network content against fresh discovery.

Source: home-mixer/scorers/weighted_scorer.rs, author_diversity_scorer.rs, oon_scorer.rs.

Section 7

7 The full pipeline, end to end

Home Mixer runs everything above as an ordered series of stages. Here is the whole journey of a feed request:

👤
1. Query Hydration

Fetch context about you: recent engagement history, who you follow, muted keywords, topics, served history.

📥
2. Candidate Sourcing

Pull posts from Thunder (in-network) and Phoenix Retrieval (out-of-network), plus ads, who-to-follow, etc.

💧
3. Hydration

Enrich each candidate with post text, media, author info, video duration, subscription status.

🚫
4. Pre-scoring Filters

Drop posts that are duplicates, too old, your own, from blocked/muted authors, contain muted keywords, or were already seen.

🧠
5. Scoring

Phoenix Scorer (ML predictions) → Weighted Scorer (combine) → Author Diversity Scorer → OON Scorer.

🎯
6. Selection

Sort by final score, keep the top K candidates.

🛡️
7. Post-selection Filters

Final visibility checks (deleted / spam / violence / gore) and conversation-thread dedup.

📤
8. Side Effects & Response

Cache request info for next time, then return the ranked feed over gRPC.

Independent sources and hydrators run in parallel; the candidate-pipeline/ crate is a reusable framework defining the Source, Hydrator, Filter, Scorer, Selector, and SideEffect traits.
Section 8

8 Key design choices

Four ideas that shape the whole system.

01

No hand-engineered features

There are no manual “this post is good because X” rules. The transformer learns relevance entirely from your raw engagement sequence.

02

Candidate isolation

The attention mask keeps each candidate's score independent of its batch — consistent, cacheable, parallelizable. (Section 5.)

03

Hash-based embeddings

Users, posts and authors map through several hash functions into fixed-size embedding tables. Bounds memory and gracefully handles brand-new IDs.

04

Multi-action prediction

Predicting many actions instead of one score means the feed can be re-tuned by changing weights — no model retrain required.

One-sentence summary: retrieval turns you and every post into vectors and grabs the nearest few hundred; ranking runs a Grok transformer that predicts your probability of each engagement; a weighted sum (plus diversity and discovery dials) sorts what you finally see.
Section 9

9 Why your posts have such a brief shelf life

Most feeds treat recency as a soft preference — older content can still surface if the relevance signal outweighs the age penalty. X treats it as structural. Four independent layers of the system each erase your post from consideration as it ages, and they stack.

Retrieval corpus
demo: 6h window
Thunder retention
in-memory eviction
Post-age bucket
hard cap at 80h
AgeFilter
pre-scoring drop
0h 6h 24h 48h 80h ~4d
Each bar's colored portion is how long your post still passes that gate. The gates run in parallel — the post stays in the feed only while all four are still active. Values shown are pulled from the released code where known; Thunder's exact retention and AgeFilter's threshold aren't published, so those bars are illustrative.

1. The retrieval corpus is a rolling window

The demo corpus shipped with the open release is ~537K posts from a 6-hour slice. Production is larger but the same shape: indexing every post ever made into a hot vector index is infeasible, so the out-of-network discovery path only searches a recent window of the firehose. Once your post falls out, strangers can't be shown it through retrieval at all.

2. Thunder evicts old posts from memory

Thunder — the in-network store — explicitly trims posts older than a retention period. After the cutoff your post isn't down-weighted for your followers; it's just gone from the in-network candidate pool. No database fallback, no slow path. It just stops being a candidate.

3. Post age is a learned feature in the ranker

recsys_model.py defines POST_AGE_MAX_MINUTES = 4800 — that's 80 hours, ~3.3 days. The model bins post age into 1-hour buckets up to that ceiling and learns an embedding per bucket. Because training data is dominated by engagement that happens in the first few hours after posting, the model absorbs “old = uninteresting” as a learned prior. Nobody told it recency matters; it figured that out from the engagement distribution and now imposes it on every future post.

4. AgeFilter is a hard cutoff

Before scoring even runs, candidates older than a threshold are dropped outright. By the time gates 1–3 have done their work this one is usually redundant — but it makes sure nothing slips through.

The asymmetry in one line: on YouTube a four-year-old video can resurface if relevance outweighs the age penalty. On X that path doesn't exist — once the gates close, the only way back into the algorithm is a human linking to your post from somewhere outside it.
Section 10

10 What the algorithm can't see

Engagement optimization isn't a quirk of X — it's the genre. TikTok, Reels, YouTube Shorts, LinkedIn all do roughly the same thing for the same reason: clicks, dwell, replies are what you can measure cheaply at scale, so they become what you optimize. The blind spots that creates are common to all of them.

What the model can see vs. what it can't

Measured — and rewarded

  • Did you favorite it
  • Did you reply, quote, repost
  • How long you dwelled on it
  • Did you click through, expand the photo, watch the video
  • Did you click the author's profile or follow them
  • Did you share via DM or copy-link
  • Did you mute / block / report it (negative weight)

Unmeasured — and therefore invisible

  • Was the post factually accurate
  • Was it original reporting or a rehash
  • Did it broaden your perspective or narrow it
  • Did you regret reading it ten minutes later
  • Did dwell mean interest, confusion, or fury
  • Would you have preferred it next week, not now

The feedback loop that locks it in

Because the model trains on engagement it has already shaped, it can't separate “you engaged because the post was good” from “you engaged because the algorithm pushed it onto you.” The two look identical in the training data. So the system spirals toward whatever it happened to start showing:

Algorithm shows a post User engages (or scrolls past) Model learns “this was relevant” Shows more like it the loop closes here — can't tell discovered preference from created one

The arc on top is where the damage is done. Whatever the model shows you tomorrow is conditioned on what it showed you yesterday, and the labels can't disentangle the two. Dwell-time makes this worse: rage-stopping, curiosity, and confusion all generate dwell, and the model rewards them the same as genuine interest.

A few other seams worth knowing

Sharper optimization of a slightly-wrong target gives you a feed that's increasingly good at being slightly-wrong. Every platform in this genre lives with that. X is not uniquely guilty — it's just unusually legible about it, because the code is right in front of us.