← System design

Social & Feed

Design News Feed

A home feed is a merge problem dressed up as a list. Every time someone opens the app, you have to gather recent posts from a few hundred accounts they follow, drop what they should not see, score the rest and return a page in a quarter of a second. The twist is that follower counts are wildly uneven, so the strategy that works for a person with 300 followers falls over for an account with 40 million.
Core: hybrid push and pull timelines320M daily users25 feed reads per post writtenFirst page p99 under 250 ms

01

Scope and quality bar

02

Scope and quality bar

Functional
  • Publish a post containing text, up to four photos, or a link preview.
  • Follow and unfollow accounts; the follow is one-directional and needs no approval for public profiles.
  • Open a home timeline built from followed accounts, ordered by a relevance model rather than strictly by time.
  • Scroll indefinitely with stable paging, so the same post never appears twice in one session.
  • React, comment and reshare, with visible counts on each post.
  • Threaded comment replies, two levels deep in the default view.
  • Optional alerts when a close friend or a starred account publishes.
Non-Functional
  • First page of the home timeline returns in p99 under 250 ms from the API edge.
  • The read path stays available at 99.95%; publishing may queue during an incident but must not lose a post.
  • A new post shows up in most followers' timelines within 5 seconds; minutes are tolerable for the largest accounts.
  • Accounts with tens of millions of followers must not create write storms or hot partitions.
  • Deleted posts and blocked authors disappear from every timeline within a minute.
  • Reaction counts may lag by a few seconds and may be approximate in the UI.
Questions worth asking first

Pin down how skewed the follower distribution is, whether the feed is ranked or chronological, and how fresh a post has to be. Those three answers decide whether you can precompute timelines at all. Also ask whether the feed mixes in content from accounts the user does not follow, such as recommendations or ads. If it does, the candidate set is no longer just the follow graph, and you should say you will treat injected content as a separate source that joins at the ranking stage.

02

Back of the envelope

03

Back of the envelope

Our assumptions: 320 million people use the app daily, each follows about 220 accounts, and the average user publishes something roughly every two and a half days. A typical person opens the app eight times a day and pulls about one and a half pages of 20 posts per session.

QuantityWorkingResult
Posts published per day320M x 0.4about 128M
Publish rate128M / 86,400 s, peak 3x1,480 / s, peak about 4,400 / s
Timeline page requests per day320M x 8 opens x 1.5 pagesabout 3.84B
Timeline read rate3.84B / 86,400 s, peak 2.5x44,400 / s, peak about 111,000 / s
Read to write ratio3.84B pages / 128M posts, rounded down for retriesabout 25 : 1
Timeline inserts if every post is pushed128M x 220 average followersabout 28B per day, 326,000 / s
Timeline entry size8 B post id + 4 B author id + 4 B time + flagsabout 20 B packed
Timeline cache, packed320M users x 500 entries x 20 Babout 3.2 TB, 9.6 TB with 3 replicas
Same cache as Redis sorted setsabout 64 B per member with overheadabout 10 TB before replication
Post metadata per day128M x about 1.2 KBabout 154 GB, 56 TB a year
Photo uploads per day22% of posts x 1.6 photos x 450 KB across renditionsabout 20 TB into object storage
Reactions per day320M x 309.6B, about 111,000 / s
The number that shapes everything

Pushing every post to every follower means 326,000 timeline writes per second on average, and the average hides the tail. One account with 40 million followers posting five times an hour adds another 55,000 writes per second on its own. The design has to stop treating that account like everyone else.

What stays cheap

The raw post rate is only a few thousand per second, and post bodies are small. Storing posts is easy; delivering pointers to them is the job. Reactions are the other heavy write stream, and they never need to be exact at read time.

03

API design

04

API design

POST/v2/postsPublish a post
// Media is uploaded first to a presigned URL; the post only references it // Idempotency-Key: 9b2e71d0 so a flaky network cannot double-post { "body": "Finally shipped the new onboarding flow", "media_ids": ["m_77Qx2", "m_77Qx3"], "audience": "followers", // or "close_friends" "reply_policy": "anyone" } // 202 Accepted: stored durably, delivery to timelines happens async { "post_id": "1840219937701945344", "created_at": "2026-10-01T11:04:12Z" }
GET/v2/timeline/home?limit=20&cursor=eyJzIjoiYjQ0...Fetch a page of the ranked home feed
{ "items": [ { "post_id": "1840219937701945344", "author": { "id": "u_5521", "handle": "mira.codes" }, "body": "Finally shipped the new onboarding flow", "counts": { "reactions": 412, "comments": 38, "reshares": 9 }, "viewer_reacted": false, "reason": "followed" // or "suggested", "ad" } ], "next_cursor": "eyJzIjoiYjQ0Iiwi..." // opaque: snapshot id + offset }
POST/v2/posts/{post_id}/reactionsReact to a post
{ "type": "like" } // 200 even if the reaction already exists: the call is a set, not an increment // DELETE on the same path removes it
POST/v2/followsFollow an account
{ "target_id": "u_90310" } // Side effect: a backfill job copies the target's last 30 posts into the // follower's timeline so the next refresh is not empty of them

Two choices in this API carry weight. Publishing returns 202 because the post is safe once the post store acknowledges it, and the fan-out after that is background work the client should not wait on. The timeline uses an opaque cursor that encodes a ranking snapshot, not a page number. Ranked feeds reorder constantly, and an offset into a list that keeps changing is how users end up seeing the same post on page one and page two.

04

How the pieces fit

05

How the pieces fit

Publish path, timeline build and read pathDiagram
flowchart LR
	C["Mobile or web client"] --> GW["API gateway"]
	GW --> PS["Post service"]
	PS --> PDB[("Post store, sharded by post id")]
	PS -. "PostCreated" .-> K[["Kafka: posts"]]
	K --> FO["Fan-out workers"]
	FO --> G[("Follow graph service")]
	FO --> TL[("Timeline cache")]
	GW --> FS["Feed service"]
	FS --> TL
	FS --> HA["Heavy author recent-post cache"]
	FS --> RK["Ranking service"]
	FS --> HY["Hydration: posts, profiles, counts"]
	HY --> PDB

There are two halves that only meet in the timeline cache. On the write side, the post service validates the post, assigns a time-sortable 64-bit id, writes the row and emits an event. Fan-out workers consume that event, look up who follows the author, and append the post id to each follower's cached timeline. Nothing on this side knows about ranking.

On the read side, the feed service assembles candidates from two places: the follower's own precomputed timeline, and the recent posts of any very large accounts they follow, which were deliberately never pushed. It merges those lists, drops anything already seen or no longer visible, asks the ranking service to score the survivors, and then hydrates the top results with post bodies, author profiles and live counts. The result is cached as a short-lived snapshot so the next page comes from the same ordering.

Post store

The source of truth for posts, sharded by post id in a wide-column or sharded MySQL setup. Reads are by id lists, so a post cache in front of it absorbs most hydration traffic.

Follow graph

Two adjacency tables, followers of X and accounts X follows, each partitioned by the first user id. Fan-out reads the first; the feed service reads the second to find heavy authors.

Timeline cache

One bounded list per active user holding the latest 500 entries. It is a derived cache, not a source of truth: anything in it can be rebuilt from the graph and the post store.

Ranking service

A stateless model server that takes a few hundred candidates plus viewer features and returns scores. It sits behind a strict deadline so a slow model degrades ordering, not availability.

Counter service

Reactions and comments land in a log first and are folded into per-post counters every few seconds. The feed reads counts from a cache, never by counting rows.

Media pipeline

Photos go straight from the client to object storage and are resized into three renditions asynchronously, then served through a CDN. The feed only ever carries media ids.

05

Deep dive: who pays for delivery

06

Deep dive: who pays for delivery

The question underneath

Assembling a feed costs work. You can pay it once per post, at publish time, by copying the post id into every follower's list. Or you can pay it once per read, by collecting posts from everyone the viewer follows when they open the app. With 25 reads per write you would think the answer is obvious, until you meet an account with 40 million followers.

Option one: build the feed when it is read

Pull is the simplest model. On each request, fetch the 220 accounts the viewer follows, read the latest few posts from each author's recent-post list, merge by time and rank. Writes are trivial, and nothing is wasted on followers who never open the app. The cost is on the hot path: 220 lookups per page, at 111,000 pages per second at peak, is about 24 million cache reads per second, and the slowest of those 220 decides your latency. Pull is fine for a small network and terrible as the only strategy at our size.

Option two: build the feed when it is written

Push flips the cost. When a post is created, workers write its id into the timeline of every follower, so reading a feed becomes a single range read of a precomputed list. That is exactly the shape a latency budget likes. The weakness is the long tail of follower counts. A single post from an account with 40 million followers turns into 40 million writes, which at a generous 500,000 writes per second for the whole fleet would take 80 seconds and block everyone else's posts behind it. It also burns memory writing into the timelines of people who have not opened the app in months.

What we do: push by default, pull for heavy authors

Split authors by follower count. Anyone under a threshold, say 75,000 followers, is pushed. Anyone above it is marked as a heavy author, and their posts go only into a small recent-post list keyed by author. At read time, the feed service takes the viewer's pushed timeline and merges in the recent posts of the heavy authors they follow. Most people follow only a handful of such accounts, so the pull side is maybe 5 to 15 extra lookups, all against a tiny, extremely hot cache that replicates well.

Two refinements make this work in practice. First, skip inactive followers during push. If a follower has not opened the app in 21 days, do not write to their timeline at all. When they come back, rebuild their timeline once by pulling from the accounts they follow. That alone removes a large share of push writes, because the follower lists of popular accounts are full of dormant users. Second, make the threshold sticky. An account hovering around 75,000 followers should not flip modes on every follow, so promote at 80,000 and demote at 60,000.

Read path for one home feed requestDiagram
flowchart TD
	R["GET /timeline/home"] --> SNAP{"Snapshot cursor valid?"}
	SNAP -->|yes| PAGE["Serve next slice of snapshot"]
	SNAP -->|no| P1["Read pushed timeline, 500 ids"]
	SNAP -->|no| P2["Read recent posts of followed heavy authors"]
	P1 --> M["Merge and dedupe"]
	P2 --> M
	M --> F["Filter: seen, deleted, blocked, muted"]
	F --> L["Light ranker trims to 300"]
	L --> H["Heavy ranker scores 300"]
	H --> S["Store snapshot for 10 minutes"]
	S --> PAGE

Ranking in two passes

A candidate set of 500 to 800 posts is too many for a large model inside a 250 ms budget, and too few to need a search engine. So rank in two passes. A light ranker, a small logistic or tree model on cheap features such as author affinity, post age and media type, trims the set to about 300 in a few milliseconds. A heavy ranker, a neural model that also uses the viewer's recent interactions and the post's early engagement, scores those 300 in batch on CPU or GPU within 40 to 60 ms. A final pass applies business rules: no more than two posts in a row from one author, spread out reshares of the same item, and insert ads or suggestions at fixed slots.

The output is written as a snapshot keyed by viewer and a short id, holding the ordered list of post ids. The cursor carries the snapshot id and an offset. Scrolling reads the next slice of the same list, so ordering stays stable. Pulling to refresh discards the snapshot and builds a new one, and posts the viewer already saw are filtered out using a small per-user seen set.

Pull only
Assemble on every read

No wasted writes, perfectly fresh, simple to reason about. Read cost grows with how many accounts a viewer follows, and the slowest shard sets the latency.

Too slow at this read rate
Push only
Copy ids to every follower

Fastest possible read. Write amplification is unbounded for large accounts, and dormant followers soak up memory and worker time.

Good until the first celebrity
Chosen
Push for most, pull for heavy authors

Keeps reads to one range scan plus a handful of hot lookups, and caps the cost of any single post. You pay with a merge step and a threshold to tune.

Bounded on both sides

06

Choices we made and their price

11

Choices we made and their price

Chosen
Timelines hold ids, posts are hydrated at read

An entry is about 20 bytes, so 500 entries per user fit in a few terabytes. Edits and deletes happen once in the post store and show up everywhere on the next read. The price is a multi-get against the post cache on every page.

Small timelines, single source of truth
Rejected
Copy full post bodies into each timeline

Saves the hydration hop, but multiplies storage by roughly 60 and turns every edit or takedown into a second fan-out. Moderation would chase copies across millions of lists.

Storage and correctness cost
Chosen
Fan-out through a durable log

The publish call ends once the post row and the event are stored. Workers can lag, retry and scale independently, and a backlog delays delivery without failing writes. Every timeline write is an idempotent set-add keyed by post id, so replays are harmless.

Publish latency independent of follower count
Rejected
Fan out inside the publish request

Works when the largest account has a few thousand followers. Beyond that the request times out, and a crash halfway leaves some followers with the post and some without, with nothing to resume from.

Breaks on big accounts
Chosen
Reaction log plus batched counters

Each reaction is a row keyed by post and user, which makes it idempotent and lets you answer "did I react". A stream job folds those into counters every two seconds, and hot posts get their counter split across 16 sub-keys that are summed at read.

Correct per user, cheap per post
Rejected
One atomic counter per post

Simple, but a viral post sends tens of thousands of increments per second to one key on one shard. You also lose the ability to undo a reaction cleanly without the per-user row.

Fine for small apps
Chosen
Comments as rows with a parent id and a root id

Fetching a thread is one query on root id, sorted by score or time, and the client builds the two visible levels. Deeper nesting is loaded on demand from the parent id. Easy to shard by root post.

Fits a two-level UI
Alternative
Path strings or closure tables

Path prefixes such as 0007.0012.0003 make whole-subtree reads one range scan, which helps forums with deep threads. For a feed where almost nobody expands beyond two levels, the extra write complexity buys little.

Worth it for deep threads

07

When things break

12

When things break

How to talk about failure

For each incident, say what the viewer notices, whether anything is lost, and what brings the system back. In a feed, the best answer is usually to serve something slightly worse rather than nothing: an older timeline, a chronological order, counts that are a minute stale.

🧊
A timeline cache shard is lost

One of the cache shards dies with its replica during a zone outage. Several million users now have no precomputed timeline, and their next request finds an empty list.

Treat an empty or missing timeline as a cache miss, not an empty feed: rebuild by pulling the latest posts from the accounts the viewer follows, cap the rebuild to the 200 most-engaged accounts, and rate-limit rebuilds per shard so recovery does not hammer the post store.
🌊
Fan-out falls behind

A major live event triples the publish rate for 20 minutes. Workers cannot keep up and consumer lag climbs to several minutes. Posts exist, but followers do not see them yet, and people assume the app is broken.

Autoscale workers on lag rather than CPU, partition the topic by author so one busy author cannot block a partition for long, and temporarily lower the heavy-author threshold so the largest accounts switch to pull until the backlog drains. Always show authors their own post immediately from the post store.
🔥
A heavy author's recent posts become a hot key

A head of state posts during a crisis. Fifty million feed requests in ten minutes all read the same recent-post list and the same post body, and the shard that owns them saturates.

Replicate heavy-author lists to every cache node and keep a copy in each feed service process for a few seconds. Hot post bodies get the same treatment. A hot key served from local memory costs nothing.
🐢
The ranking model slows down

A new model version is three times slower than expected, or the GPU pool shrinks after a bad deploy. Scoring 300 candidates now takes 400 ms and the whole feed misses its latency budget.

Give ranking a hard 80 ms deadline. If it misses, return candidates ordered by the light ranker's score, or by time as a last resort. Watch the fallback rate as a key metric, and roll out models behind a percentage flag.
🗑️
Deleted or blocked content still appears

A post is removed for policy reasons, but its id is still sitting in millions of cached timelines and in snapshots built a few minutes ago.

Never trust the timeline for visibility. Hydration checks a tombstone set and the viewer's block list on every page, so a removed post drops out on the next request even if its id is still cached. Snapshots are short-lived for the same reason.
⛓️
One slow dependency drags down everything

The profile service starts answering in two seconds. Feed service threads wait on it, connection pools fill, and soon the feed fails even for requests that only needed cached data.

Set per-dependency timeouts well inside the page budget, isolate each dependency in its own pool, and open a circuit breaker when error rates spike. With profiles down, render posts with a cached author name instead of failing the page.
IncidentWhat the viewer seesData lostWay back
Cache shard lostSlower first load, then normalNoneRebuild by pull, rate-limited
Fan-out lagNew posts arrive lateNoneScale on lag, lower threshold
Hot heavy authorPossible slow pagesNoneReplicate and cache locally
Slow rankerLess relevant orderingNoneDeadline plus fallback order
Stale deleted postNothing, if filters workNoneTombstone check at hydration
Slow dependencyDegraded cards, not errorsNoneTimeouts, bulkheads, breakers

08

Mistakes to avoid

⚠

Mistakes to avoid

🚫
Building the feed with a SQL join over posts and follows

A query that joins the follow table to the posts table and sorts by time works in a demo and collapses once a single viewer follows hundreds of active accounts spread across shards.

Instead: keep a precomputed, bounded timeline per active user and treat it as a cache you can rebuild.
🚫
Paging a ranked feed with offsets

The ranking changes between requests, so offset 20 on the second call points at a different list than the first call did. Users see repeats and miss posts.

Instead: rank once into a short-lived snapshot and page through it with an opaque cursor.
🚫
Running the heavy model on every candidate

Scoring 800 candidates with a large model on each of 111,000 requests per second is a GPU bill nobody approves, and most of those candidates were never going to make the page.

Instead: trim with a cheap model first, and spend the expensive model only on the few hundred that survive.

09

Interview advice

13

Interview advice

1

Name the skew in the first five minutes. Say that follower counts follow a power law and that the design must handle both a user with 200 followers and one with 40 million. Interviewers are waiting for you to see the celebrity problem before they raise it.

2

Explain push and pull with numbers. Compute writes per second for pure push and reads per page for pure pull. When you then propose the hybrid, it reads as the obvious conclusion instead of a memorised answer.

3

Separate candidate generation from ranking. Draw them as two boxes. It shows you understand that where posts come from and how they are ordered are separate problems with different scaling limits.

4

Store ids, hydrate late. Interviewers often ask how a deleted post leaves a million timelines. If your timelines hold only ids and hydration checks visibility, the answer is one sentence.

5

Talk about dormant users. Skipping push for followers inactive for three weeks is a cheap, real optimisation, and it shows you have thought about where the write budget actually goes.

6

Have a pagination answer ready. Snapshots and cursors come up almost every time with a ranked feed. Explain why offsets fail and what pull-to-refresh does to the snapshot.

7

Keep counters out of the feed's critical path. Reactions are a write stream as heavy as the timeline itself. Show the log plus batched aggregation, and mention sharded counters for viral posts.

8

Describe graceful degradation. If ranking, profiles or counts are slow, what does the user get? Interviewers reward a design that falls back to chronological order instead of an error screen.

9

Do not draw the final system first. Start from one database and a pull query, find where it breaks with your own numbers, then add the timeline cache, the log and the ranker in that order.

10

From one database to a global feed

15

From one database to a global feed

Stage 1: under 100,000 daily users
A single app and one relational database

The feed is a query: posts where the author is in my follow list, newest first, limit 20. With an index on author and time this is fast enough, and there is nothing to keep in sync.

Stage 2: a few million daily users
Caches and read replicas

Feed queries get slow for people who follow many active accounts. Add a post cache, a profile cache and read replicas, and cache each author's recent post ids so the feed query becomes a merge of cached lists.

Stage 3: tens of millions
Precomputed timelines and a queue

Switch to push with background workers and a durable log. The first large accounts appear, so introduce the heavy-author pull path and stop writing to dormant followers. Ranking starts as a simple scoring function.

Stage 4: hundreds of millions
Sharded stores and a real ranking stack

Posts, follows and reactions move to sharded stores. Ranking becomes a two-pass model service with a feature store, snapshots fix pagination, and counters move to log plus batch. The single primary database is retired.

Stage 5: global
Several regions, each serving its own users

Users are homed in a region with their timelines, and posts replicate between regions asynchronously. Cross-region follows add a few seconds of delay, which the freshness target already allows.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.