R8.04.1 · Twitter/X: internals & custom structures — dive 1
Timeline fan-out: pay at write, read cheaply.
When a tweet is posted, a fanout service inserts its id into every follower’s home-timeline Redis list — so a read of a timeline is a single range over one list, O(1), while the write is O(n) in the follower count.
Grounding: the Twitter case study (HdM Stuttgart, VMware Tanzu) · the BCS echo —
EchoMQ.Lanes in echo_mq/lanes.ex. Valkey is the BCS engine; Twitter’s own Redis is the case
study.
The fanout (verbatim) is “responsible for spreading out the tweet to all people following the author by inserting it to all their home timelines.”
Fan-out on write
HdM Stuttgart · the write pathA home timeline is a list of tweet ids, one per row, in time order. The write path that fills it is described verbatim: “the fanout … is responsible for spreading out the tweet to all people following the author by inserting it to all their home timelines.”
The steps, in order: a user posts a tweet, which is written once to a tweet store and given a unique id; the fanout service asks the social-graph service for the author’s follower set; and for each follower, the fanout service inserts the tweet id into that follower’s home-timeline Redis list.
The cost is asymmetric, and that is the point. A read touches one list and returns a range; it does not consult the social graph and does not scan other users’ data. A write touches as many lists as the author has followers. Twitter absorbed that asymmetry because reads vastly outnumber writes: a reported “300K QPS … spent reading timelines and only 6000 requests per second … on writes.” Paying O(n) once at write time, fifty times less often than reads happen, buys an O(1) read every time a timeline is opened. The system delivers a tweet “within 5 seconds” and serves the HTTP read “within 50 milliseconds.”
What is stored per entry is deliberately small: “Every tweet is stored only by its unique tweet id, the author’s id and some additional bits.” The list holds identities and a little metadata, not tweet bodies — the body lives once in the tweet store, fetched by id when a timeline is rendered. A small entry is what makes a list of a follower’s worth of tweets cheap to hold, and it is the same instinct EchoMQ’s lanes follow.
Fan-out on write moves the work to post time so the read stays O(1). It pays once per follower on a write, and reads — fifty times more frequent — each touch one list.
Replication, and the celebrity hybrid
HdM Stuttgart · the exceptionsTwo facts complete the picture.
Replication for fault tolerance. A home timeline is not stored once. “The user’s home timeline is stored three times in the clusters,” so a node loss does not lose a user’s feed and a read can be served from any copy. Three copies is durability-through-redundancy in RAM — the timeline is a derived, rebuildable view, replicated for availability rather than persisted as a system of record.
The celebrity problem, and the hybrid. Fan-out-on-write is cheap to read and bounded to write only while the follower count is bounded. For the largest accounts it is not: delivery “can take up to 5 minutes … when celebrities like Lady Gaga or Kylie Jenner tweet,” because inserting one tweet into tens of millions of lists takes real time. The answer is a hybrid. Most accounts use fan-out-on-write; the few enormous accounts are handled by fetch-and-merge-at-read — a reader’s timeline is assembled from their own fanned-out list plus a live pull of the handful of celebrity accounts they follow, merged at read time. The write amplification of the giant account is traded for a small read-time merge, paid only by the readers who follow it.
Insert the tweet id into every follower’s list at post time. Bounded write, O(1) read — right while the follower count is bounded.
Do not fan out a tens-of-millions-follower tweet. Pull the celebrity’s recent tweets at read time and merge into the reader’s timeline. Cheap write, small read-time merge.
The principle is a dial, not a binary. Fan-out-on-write optimises the read at the cost of the write; fetch-and-merge optimises the write at the cost of the read. Twitter ran both, choosing per account by where the cost was bearable.
The BCS echo — lanes are per-lane Redis lists
echo_mq/lanes.exEchoMQ does not store timelines. But its queue is built on the same primitive Twitter chose — the native Redis list — and one of those lists plays exactly the role of a readiness signal. A lane is a per-group pending set, and a ring lists the lanes that can be served right now. Admitting a job pushes onto the ring and signals any parked consumer; claiming rotates the ring one step. The relevant lines are verbatim from the enqueue script:
-- push the lane onto the ring, then signal a parked consumer
redis.call('RPUSH', KEYS[3], ARGV[3])
redis.call('LPUSH', KEYS[7], '1')
redis.call('LTRIM', KEYS[7], 0, 63)
RPUSH appends to the ring; LPUSH then LTRIM … 0, 63 writes a
one-byte token to a wake list and trims it to 64 elements, so a consumer blocked on that
list wakes when work appears. The claim side rotates the ring with a single
redis.call('LMOVE', KEYS[1], KEYS[1], 'LEFT', 'RIGHT') — moving the head to the tail so
service spreads round-robin across lanes. Every one of these is a list operation on a native Redis list.
A feed is a list of ids; the read is cheap because the entry is only an identity, and a list is the right structure when order matters and the entry is small.
A lane is a Redis list of branded JOB ids, and the wake list is a Redis list held to 64
elements with LTRIM … 0, 63. The element is the id; the bus rotates service across lanes
with one LMOVE.
The lane ring and the wake list
The work ring and the readiness signal are both native Redis lists; the wake list is capped to 64 so it stays one node, verbatim from the lanes module:
-- one LMOVE rotates the ring head-to-tail (round-robin fairness) local g = redis.call('LMOVE', KEYS[1], KEYS[1], 'LEFT', 'RIGHT') -- the wake list is a Redis list, held to 64 elements redis.call('LPUSH', KEYS[7], '1') redis.call('LTRIM', KEYS[7], 0, 63)
The honest difference: Twitter’s list is the feed itself, replicated three ways and read by range;
EchoMQ’s lists are a work ring and a wake signal, single-key and rotated. The shared move is the one to
keep — store an identity in an ordered list and keep the entry small, so the list stays cheap. The
worked consumer is codemojex: a guess is a job on a player’s lane, minted as a branded
JOB with EchoData.BrandedId.generate!(“JOB”) and enqueued, so the lane is
a list of ids.
Notes on ValkeyA Redis or Valkey list is stored as a quicklist: a
doubly-linked list whose nodes are listpacks. A 64-element wake list lives in a single listpack node, so the
LTRIM … 0, 63 keeps it compact regardless of how often it is pushed to. The node-size
thresholds that decide when a list splits across nodes are the subject of
R7.02 —
valkey.io/topics/data-types.
Recap — pay once at write, read cheaply forever
next → R8.04.2Fan-out on write is a deliberate trade: spread a tweet into every follower’s list at post time so a
timeline read is O(1) forever after. Twitter replicated each timeline three times for fault tolerance and ran
a hybrid path for the few accounts whose follower counts made write-time fan-out too slow. EchoMQ uses the
same primitive — a native Redis list — for its lane ring and its wake signal, storing an id per
entry and capping the wake list at 64 with LTRIM … 0, 63. The next dive measures what a
list of ids costs in memory, and the encoding Twitter’s scale drove into core Redis.
References
sources · relatedSources
- HdM Stuttgart CS Blog — How to Scale: Real-time Tweet Delivery Architecture at Twitter — fan-out on write, the per-follower Redis-list home timeline, what is stored per entry, 3× replication, the read/write asymmetry, and the celebrity hybrid.
- VMware Tanzu — Staple Yourself to a Tweet … 30 Billion Redis Updates Per Day — the home-timeline Redis tier framed at roughly 30 billion Redis updates per day, against 400 million tweets a day.
- Valkey — Data types — the list as a quicklist of listpack nodes, and the node-size thresholds the wake list stays under.
Related in this course
- R8.04 · Twitter/X: internals & custom structuresthe module hub
- R8.04.2 · Quicklist & memorythe next dive — what a list of ids costs
- R8 · Production & Operationsthe chapter
- /bcs/overviewthe branded id, the identity stored in a lane
- /echomq/queuethe Queue pillar — jobs, lanes, the lists behind the bus