System Design Primer · llms.txt
Beyond the primer: design questions
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
A news feed collects recent posts from the people and pages a user follows, orders or ranks them, and shows them as one scrolling list. When the user opens the app, they expect a fresh feed in a few hundred milliseconds, and they expect scrolling to keep going without repeats or gaps.
This is a common interview question because it forces one central decision: do you build each user's feed when someone posts (fan-out on write, or push) or when someone reads (fan-out on read, or pull)? Each choice breaks down somewhere, most visibly on accounts with tens of millions of followers. The question also covers caching timelines for hundreds of millions of users, pagination over a list that keeps changing, and ranking.
It builds on the primer's database, cache and asynchronism chapters, and on the extra chapters on event streaming, unique IDs and rate limiting.
These numbers are assumptions for a large consumer app, not figures from any real one:
Two things stand out. Fan-out on write turns each post into hundreds of cache writes. And the cache's size depends on how timelines are encoded as much as on how many IDs they hold.
-- MySQL syntax. Posts are sharded by user_id; follows by follower_id,
-- with a second copy sharded by followee_id for "who follows X?".
CREATE TABLE users (
user_id BIGINT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE follows (
follower_id BIGINT NOT NULL,
followee_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (follower_id, followee_id),
INDEX idx_followee (followee_id)
);
CREATE TABLE posts (
post_id BIGINT PRIMARY KEY, -- time-ordered, Snowflake style
user_id BIGINT NOT NULL,
content TEXT,
media_url VARCHAR(1024),
created_at TIMESTAMP NOT NULL,
INDEX idx_user_created (user_id, created_at DESC)
);
post_id is a time-ordered ID (see unique IDs), so sorting by ID is close to sorting by creation time, and any server can mint one without coordination.
Publishing a post:
POST /api/v1/posts HTTP/1.1
Content-Type: application/json
{
"content": "Excited to launch our new product today!",
"media_url": "https://cdn.example.com/images/prod.jpg"
}
HTTP/1.1 201 Created
Content-Type: application/json
{
"post_id": 98234190241,
"created_at": 1735689600
}
Reading the feed, one page at a time. The cursor is an opaque token from the previous response:
GET /api/v1/feed?limit=20&cursor=eyJwb3N0X2lkIjo5ODI HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
{
"items": [
{
"post_id": 98234190241,
"user_id": 14205,
"content": "Excited to launch...",
"created_at": 1735689600
}
],
"next_cursor": "eyJwb3N0X2lkIjo5ODA"
}When someone posts, who does the work of putting that post into feeds: the system at write time, or each reader at read time?
Posts are stored once, under their author. No feeds are precomputed.
When Alice opens the app:
SELECT ... FROM posts WHERE user_id IN (...300 IDs...) ORDER BY created_at DESC LIMIT 20. With posts sharded by user_id, that's a query to many shards, each walking the (user_id, created_at) index for its authors.Every user has a precomputed timeline in an in-memory store: a list of post IDs, newest first. Redis sorted sets are a common choice, with the post's timestamp as the score.
When Bob publishes a post:
post_id to that follower's timeline, for example ZADD timeline:alice <timestamp_ms> <post_id>, then trims the timeline to its newest 500 entries.ZREVRANGE timeline:alice 0 19, which costs O(log N + M) for M returned entries: well under a millisecond inside Redis.Large platforms combine the two. Twitter described this publicly in 2012–2013: ordinary accounts are pushed to followers' timelines, while accounts with very large followings are skipped during fan-out and merged in when a timeline is read.
The read-side cost is now proportional to the number of celebrities a user follows, not the number of accounts. Celebrity post lists are read by millions of users, which makes them ideal cache entries: one hot key per celebrity, replicated across several cache nodes so one node doesn't take all the reads.
Two refinements are common:
user_id), with a post_id minted from a time-ordered ID generator.{post_id, author_id, created_at} to a Kafka topic and returns 201 to the client. Fan-out happens off the author's request path.timeline:<follower_id> and trim it to 500 entries.author_id, so one author's posts are processed in order, and a burst of posts is absorbed by the queue instead of by the cache.GET /api/v1/feed?limit=20.With every step served from memory, the whole read takes a few tens of milliseconds, comfortably inside the 200 ms budget. Ranking is usually the most expensive step.
The obvious way to page through a list is OFFSET and LIMIT:
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 20;
For a feed this goes wrong in two ways:
OFFSET 20) now starts at what was position 16, so she sees posts 16–20 again. If posts above her position are deleted instead, items shift the other way and she skips some.OFFSET 100000 makes the database walk 100,020 index entries and throw away the first 100,000, so deep pages get slower and slower.A cursor records where the last page ended: the sort key of the last item the client saw. The next query starts strictly after it:
SELECT post_id, content, created_at
FROM posts
WHERE (created_at, post_id) < (:cursor_created_at, :cursor_post_id)
ORDER BY created_at DESC, post_id DESC
LIMIT 20;
post_id is in the comparison as a tie-breaker, because two posts can share a timestamp. With an index on the sort columns, this is one index seek followed by reading 20 entries: O(log n), the same cost for page 1 as for page 5,000. New posts arriving at the top don't move the position the cursor points to.
In a Redis sorted set, the same idea uses the score:
ZRANGE timeline:alice (1735689600123 -inf BYSCORE REV LIMIT 0 20
The ( makes the bound exclusive, so the query returns the 20 entries with scores strictly below the last one seen. (ZREVRANGEBYSCORE does the same on older Redis versions.) Scores are 64-bit floating-point numbers, which hold integers exactly only up to 2⁵³, so a full 64-bit Snowflake ID can't safely be used as a score. Use a millisecond timestamp, and put the last post ID in the cursor too, so ties on the same millisecond don't drop posts.
A ranked feed isn't sorted by any single key, so keyset pagination doesn't apply directly. A common approach is to rank a batch of candidates when the session starts, store that ordered list briefly, and have the cursor point into it. New posts appear on the next refresh, not in the middle of a scroll.
Early feeds were strictly reverse-chronological. Most large platforms now rank by predicted interest, often with a chronological option alongside.
Ranking usually runs in two stages:
Affinity: how often the reader likes, comments on or messages the author.
Content type: whether this reader tends to engage with video, photos or text.
Freshness: older posts are discounted. One simple form is an exponential decay:
score = predicted engagement × e^(−λ × age)
With λ = ln 2 ÷ 6 hours, a post's score halves every 6 hours.
Early engagement: posts that are collecting likes and replies quickly get a boost.
The feed service sorts by the final score, applies rules such as "no more than two posts in a row from the same author", and returns the top 20. Because ranking takes a few hundred candidates as input, the timeline cache has to hold more than one page per user, which is one reason it keeps hundreds of entries.
With pure fan-out on write, each post is copied into the timeline of every follower. A post from an account with 50 million followers is 50 million cache writes. That takes a large share of the fan-out fleet's capacity for a minute or more, delays posts from everyone else that are queued behind it, and makes the post show up for different followers at very different times.
The hybrid model skips fan-out for accounts above a follower threshold. Their posts are stored once and kept in a small, heavily cached list of recent posts per author. When a user loads the feed, the feed service merges their precomputed timeline with the recent posts of the few celebrities they follow. The cost moves to read time, but it's proportional to the number of celebrities followed, which is small, and those lists are hot cache entries shared by millions of readers.
Because new posts keep arriving at the top. Suppose a user sees page 1 (OFFSET 0 LIMIT 10), positions 1 to 10. Three new posts are published. Everything shifts down by three, so the posts that were at positions 8, 9 and 10 are now at 11, 12 and 13. Page 2 (OFFSET 10 LIMIT 10) returns positions 11 to 20, which starts with those three posts again.
A cursor records the sort key of the last item shown, such as (created_at, post_id), and the next page asks for items strictly after it. Inserts above that point don't change where it is, so there are no duplicates.
Cap each timeline and expire timelines nobody reads:
ZADD, the worker keeps only the newest 500 entries. Sorted-set ranks count up from the lowest score, which is the oldest post, so the command that removes everything except the 500 highest scores is ZREMRANGEBYRANK timeline:<user_id> 0 -501. Older posts are still reachable by falling back to a pull query if someone scrolls that far.Not necessarily. With these estimates, pushing each post to 300 followers is 30 billion timeline writes a day, ten times the 3 billion feed loads. Fan-out on write is a good trade because those writes are cheap, asynchronous and off the user's path, while reads are on the path and need predictable latency. It is not less work. That's also why skipping inactive followers and celebrities matters so much: they're where most of the wasted writes are.
ZADD, ZRANGE ... BYSCORE REV LIMIT, and ZREMRANGEBYRANK, used above for timelines and cursors.Previous: Design a URL shortener (like TinyURL or Bitly) · Next: Design a real-time gaming leaderboard