Skip to content

How Twitter Served 300,000 Timelines Per Second

Mar 202612 min read

_This post is a plain breakdown of how Twitter handled timeline scale, why the first approach broke, and why the final solution is a hybrid._


The Problem

When I strip Twitter down to the basics, there are really only two product operations that matter:

  • Post Tweet: a user publishes a new message to their followers
  • Home Timeline: a user views tweets from the people they follow

Here are the numbers Twitter published (Nov 2012):

Scroll horizontally to view all columns

OperationAveragePeak
Post Tweet (writes)4,600 req/sec12,000 req/sec
Home Timeline (reads)300,000 req/sec

The read-to-write ratio is roughly 65x. People read way more than they write. That asymmetry is the whole story here.

What surprised me the first time I studied this: 12,000 writes/sec is not the scary part. A solid relational setup can handle that. The hard part is _fan-out_: one tweet may need to show up in millions of home timelines almost immediately.


The Schema

At the core, Twitter's model has three tables.

tweets: the content

Scroll horizontally to view all columns

idsender_idtexttimestamp
112"Acquitted again, very legal"1000
25"Opposition found alive (mistake)"1001
312"Bankruptcy is just a strategy"1002
48"Missile landed on... somewhere. Geography is hard."1003

Every tweet lives here. Notice sender_id = 12 appears twice: rows 1 and 3 are both from Trump. This table is just tweet content, nothing else (and totally not legally incriminating).

users: the profiles

Scroll horizontally to view all columns

idscreen_nameprofile_image
5putinputin.jpg
8kim_jong_unkim_jong_un.jpg
12trumptrump.jpg

This is profile data only. No tweets, no follow graph. If I see sender_id = 12 in tweets, I resolve id = 12 here and get "trump".

follows: the relationships

Scroll horizontally to view all columns

follower_idfollowee_idmeaning
1005User 100 follows Putin
10012User 100 follows Trump
10112User 101 follows Trump

This table stores the follow graph. One row = one relationship.


Follower vs. Followee

I used to mix this up, so here is the simple version:

  • Follower: the person doing the following. If you follow Trump, _you_ are the follower.
  • Followee: the person being followed. Trump, in this case, is the followee.

In follows, follower_id = 100 means user 100 is doing the following. followee_id = 12 means user 12 is being followed.

So when Trump tweets, the question is: _"Who has followee_id = 12?"_ That result is everyone whose timeline might need an update.


Approach 1: Query at Read Time

Twitter's original approach was straightforward: writes go into tweets, and timelines are computed on demand at read time.

The SQL for fetching a home timeline:

SELECT tweets.*, users.*
FROM tweets
  JOIN users ON tweets.sender_id = users.id
  JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
ORDER BY tweets.timestamp DESC

What each line does

SELECT tweets., users.: grab tweet content plus author info.

FROM tweets: start from tweets.

JOIN users ON tweets.sender_id = users.id: attach author profile to each tweet.

JOIN follows ON follows.followee_id = users.id: keep tweets from accounts the viewer follows.

WHERE follows.follower_id = current_user: filter everything down to one user's home timeline.

Tracing through the example

Say I am user 100 and I follow Putin (5) and Trump (12):

  1. The query looks in follows where follower_id = 100: finds followee_id 5 and 12
  2. Matches those followee IDs against users: retrieves Putin and Trump's profiles
  3. Matches those user IDs against tweets via sender_id: retrieves tweets 1, 2, and 3
  4. Kim Jong Un's tweet (id = 4, sender_id = 8) never appears: user 8 is not in your follows

Why this broke at scale: 300,000 timeline reads/sec means hammering this multi-join query constantly. Even with indexes, this gets expensive fast. That pushed Twitter to move work away from reads.


Approach 2: Fan-Out on Write

The key idea I took from this: if reads outnumber writes by 65x, pay more at write time so reads are cheap.

Instead of building timelines on demand, build them when tweets are created. Each user gets a precomputed cached timeline (like a mailbox), ready to read.

The mailbox analogy

This is what happens when Trump tweets:

  1. The tweet is saved to the global tweets table
  2. A background worker queries: SELECT follower_id FROM follows WHERE followee_id = trump_id
  3. The worker gets back a list of all Trump's followers (say, 31 million people)
  4. For each follower, it prepends the new tweet to the front of their cached timeline

When I open the app, the timeline is basically a cache fetch. No heavy joins in the hot read path.

How the cache gets updated

The important part: this is incremental. We do not rebuild full timelines. We prepend the new tweet, which is O(1) on structures like Redis lists.

Before Jack's new tweet:

Your mailbox: [T3, T1]

After Trump posts tweet T5 and the fan-out worker runs:

Your mailbox: [T5, T3, T1]

The existing entries are untouched. Only the new tweet is inserted at the front.

The math

Average Twitter user has approximately 75 followers.

4,600 tweets/sec × 75 followers = 345,000 cache writes/sec

At first glance, this looks like more total work. It is. But the work is cheaper:

  • A cache write is a list prepend in Redis: microseconds, no disk I/O, no locking
  • A JOIN query involves scanning indexed B-trees, merging results from multiple tables, sorting: milliseconds, disk-bound

So we trade 300,000 expensive read queries for roughly 345,000 cheap cache writes. In practice, that is often a huge win.


The Full Data Pipeline

Twitter tweet delivery pipeline diagram

The three numbers that matter:

Scroll horizontally to view all columns

StageRateNature
Raw tweet ingestion4,600 writes/secCheap: one row insert
Fan-out to caches345,000 writes/secFast: Redis list prepend
Timeline reads300,000 reads/secTrivial: cache fetch

Approach 1 vs Approach 2

Scroll horizontally to view all columns

AspectApproach 1Approach 2
Work timingWork done at read timeWork done at write time
Read costHigh: requires 3-table JOINNear-zero: cache fetch
Write costLow: single row insertHigh: fan-out writes to many caches
Scaling issueBreaks with high read volumeBreaks with users having massive follower counts
Twitter usageInitially usedLater switched to this approach

The Celebrity Problem — and the Hybrid Solution

Fan-out on write has a ceiling. The average account might have ~75 followers, but celebrity accounts have tens of millions. And autocrats with state media have _unlimited_ angry followers, whether they follow voluntarily or face consequences.

If one celebrity tweet triggers 30-80 million fan-out writes, queues back up and latency spikes. (See also: what happens when the guy with the nuclear button tweets at 3am.)

So the production answer is a hybrid:

  • Normal users: fan-out on write (Approach 2). Their tweets are pre-distributed into follower caches immediately.
  • Celebrities: no fan-out. Their tweets stay in the global tweets table.

When I request my home timeline, the system merges:

  1. Your pre-built cache (tweets from normal users you follow)
  2. A small real-time query for tweets from celebrities you follow (Approach 1)

This stays manageable because most users follow only a small number of celebrity accounts.

That is the big lesson for me: architecture decisions should follow workload shape, not ideology.


The Core Principle

This case study captures a tradeoff I keep seeing in distributed systems:

Do more work at write time so the common path is trivially cheap.

Twitter optimized for reads because reads dominated writes.

When I design systems now, I start with one question: _what is my read/write ratio, and where should I pay cost?_ That one answer often determines the rest of the architecture.

Related Posts