Worked Design — News Feed / Timeline

6 interview angles 6 min read source

Worked Design — News Feed / Timeline

The canonical “design Twitter/Instagram feed” question. The whole problem is fan-out on write vs fan-out on read, and the senior answer is the hybrid. Stack: FastAPI + Postgres + Redis + Celery/SQS.

1. Requirements

Functional: users post; users follow other users; a user’s home feed shows recent posts from everyone they follow, newest first; the feed paginates.

Non-functional: feed read must be fast (p99 < 200 ms) — it’s the most-hit endpoint in the product; read-heavy (people scroll far more than they post); eventual consistency is fine for the feed (a post showing up a second or two late is acceptable), with one exception — read-your-writes for the author (you must see your own post immediately).

Scope cuts: no ranking/ML in v1 (reverse-chronological); no media pipeline; no notifications.

2. Scale

100M DAU, ~2 posts/user/day        → ~200M posts/day → ~2,300 writes/sec avg
each user reads their feed ~10×/day → 1B feed reads/day → ~12,000 reads/sec avg, ~30k peak
read:write ≈ 5:1 ... but the real asymmetry is per-operation cost

The decisive fact isn’t the ratio — it’s where you pay the cost: at write time or at read time. That’s the whole design.

3. API

POST /posts          { body }                  → { post_id }
GET  /feed?cursor                              → { posts[], next_cursor }
POST /follow         { target_user_id }

Pagination is cursor-based, not offset. Offset pagination (LIMIT 20 OFFSET 9980) gets slower the deeper you scroll and breaks when new posts shift everything. A cursor (the timestamp+id of the last seen post) gives stable, constant-cost paging.

4. Data model

CREATE TABLE posts (
    id BIGINT PRIMARY KEY, author_id BIGINT, body TEXT, created_at TIMESTAMPTZ
);
CREATE TABLE follows (
    follower_id BIGINT, followee_id BIGINT, PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_posts_author_time ON posts (author_id, created_at DESC);

The feed itself — the per-user precomputed list — lives in Redis (a sorted set per user, score = timestamp), not Postgres. Postgres is the durable source of truth; Redis holds the materialized feeds.

5. The core decision — fan-out on write vs read

Fan-out on read (pull)

On every feed request: get the user’s followees, query recent posts from all of them, merge, sort, return.

  • Write is cheap — a post is just one insert.
  • Read is expensive — a user following 1,000 people does a 1,000-way query+merge on every feed load. At 30k feed QPS this crushes the DB.
  • Good for: users who follow huge numbers of accounts; or low-traffic systems.

Fan-out on write (push)

When a user posts, immediately push that post’s id into the precomputed feed (Redis sorted set) of every follower.

  • Read is cheap — the feed is already materialized; a feed load is one Redis ZREVRANGE. This is what makes p99 < 200 ms achievable at 30k QPS.
  • Write is expensive — a post by someone with 1M followers means 1M Redis writes (the “fan-out amplification” problem). Done async via a queue, but a celebrity post is still a huge write burst.
  • Good for: the common case — most users have a manageable follower count.

The hybrid — the senior answer

On post by user U:
  if U.follower_count < THRESHOLD (e.g. 10,000):
      fan out on write — push post_id into each follower's Redis feed (async)
  else:  # celebrity
      do nothing at write time

On feed read by user R:
  base_feed   = R's precomputed Redis feed              (the fan-out-on-write part)
  celeb_posts = recent posts from celebrities R follows (fan-out-on-read, small set)
  return merge(base_feed, celeb_posts) sorted by time, paginated

Fan-out-on-write for the 99% of normal accounts (cheap reads), fan-out-on-read for the handful of celebrities each user follows (avoids the million-write storm). At read time you merge a mostly-ready feed with a tiny live query. State this explicitly — the hybrid is the thing being tested.

6. Architecture

POST /posts ─► API ─► Postgres (durable insert)

                       └─► fan-out job ─► Queue ─► fan-out workers
                                                     └─► for each follower: ZADD feed:{follower} score=ts post_id
                                                         (skip if author is a celebrity)

GET /feed ─► API ─► Redis: ZREVRANGE feed:{user}        (precomputed)
                  + Postgres: recent posts from followed celebrities (small)
                  ─► merge, sort, hydrate post bodies, return
  • Feed entries store post ids, not full posts — hydrate bodies from Postgres (or a post cache) at read time. Keeps the Redis feeds small.
  • Trim each Redis feed to the most recent ~1,000 entries — nobody scrolls a year back; the deep tail is a rare fan-out-on-read.
  • Fan-out workers autoscale on queue depth.

7. Read-your-writes

Fan-out-on-write is async, so for a few seconds after you post, your post hasn’t landed in your own feed yet. The fix: when serving a user’s feed, also pull their own most recent posts directly and merge them in. Cheap (it’s one user’s posts), and it guarantees you always see your own post immediately even though the global fan-out is still in flight.

8. Bottlenecks & trade-offs

  • Celebrity fan-out storm — solved by the hybrid (celebrities are pulled, not pushed). The threshold is a tuning knob.
  • Fan-out lag — during a spike, the fan-out queue backs up and posts take longer to appear in followers’ feeds. Acceptable (eventual consistency was in the requirements); read-your-writes covers the author.
  • Redis memory — feeds × ~1,000 entries × 8 bytes per id. For 100M users that’s ~800 GB → a Redis cluster, sharded by user id. Trimming feeds and storing only ids keeps it bounded.
  • Redis feed lost — it’s a cache; if a shard dies you rebuild a user’s feed from Postgres (the durable posts + follows). Slower for those users until rebuilt, but no data loss.
  • Ranking — v1 is reverse-chron. A ranked feed (engagement-scored) changes the read path: you’d over-fetch candidates and score them, but the fan-out structure underneath stays the same.
  • Inactive users — fanning out to followers who never log in wastes writes. Optimization: don’t fan out to long-inactive users; rebuild their feed on demand if they return.

Interview angle

  • “Fan-out on write or fan-out on read?” — neither alone. Fan-out on write makes reads cheap (precomputed feed, one Redis range query) but a celebrity post is a million-write storm. Fan-out on read makes writes cheap but a user following thousands does a thousand-way merge on every load. The answer is the hybrid: push for normal accounts, pull for celebrities, merge at read time.
  • “Where does the feed actually live?” — a precomputed per-user list in Redis (sorted set, score = timestamp), holding post ids. Postgres is the durable source of truth for posts and follows; Redis holds the materialized feeds and is rebuildable if lost.
  • “How do you guarantee a user sees their own post immediately?” — read-your-writes: fan-out is async so your post may not be in your Redis feed yet — so at feed-read time also pull the user’s own recent posts directly and merge them in. Cheap, and it closes the async gap for the author.
  • “Why cursor pagination, not offset?” — offset gets linearly slower as you scroll deep and breaks when new posts shift the window. A cursor (last-seen timestamp+id) is constant-cost and stable under new inserts.
  • “What’s the memory cost of precomputed feeds?” — feeds × ~1,000 trimmed entries × 8-byte ids; for 100M users ~hundreds of GB → a sharded Redis cluster. Storing only ids and trimming the tail keeps it bounded; the deep tail falls back to a rare fan-out-on-read.
  • “What breaks during a traffic spike?” — the fan-out queue backs up, so posts appear in followers’ feeds with more lag. That’s acceptable — the feed was specified as eventually consistent — and read-your-writes ensures the author still sees their own post instantly.