← Problems

News Feed (Twitter / X timeline)

mediumsenior / staff
Start timed practice

Users post short messages and follow others; generate a home timeline that is fast to read and tolerates celebrity accounts.

Requirements

Functional

  • Post a short message (text up to 280 chars, optional images/video).
  • Follow and unfollow other users.
  • Home timeline: the posts of everyone I follow, newest first, with infinite scroll.
  • User timeline: one user's own posts.
  • Optional: likes, replies, reposts; a ranked (non-chronological) home timeline.

Non-functional

  • Home timeline p99 < 200 ms from the API, including the first page of posts.
  • Posting is eventually consistent: a new post may take a few seconds to appear in followers' timelines, but must appear in the author's own timeline immediately.
  • Highly available for reads; a stale timeline beats an error page.
  • Scale: hundreds of millions of daily active users; follow graph is heavily skewed (most users < 1K followers, a few have 50M+).
  • Durable: a post is never lost once the client receives 201.

Out of scope

  • Search, trends, direct messages, ads auction, spam detection.

Back-of-envelope estimates

Assumptions

  • 300M DAU. Each user opens the home timeline 10×/day and posts 0.2×/day on average.
  • Average user follows 300 accounts and has 300 followers (the median is far lower; the mean is pulled up by celebrities).

Traffic

  • Timeline reads: 300M × 10 / 86,400 ≈ 35K reads/s average; plan for 3× peak ≈ 100K reads/s.
  • Posts: 300M × 0.2 / 86,400 ≈ 700 posts/s average, ~2K/s peak.
  • Fan-out on write: 700 posts/s × 300 followers ≈ 210K timeline inserts/s average. That is why a single celebrity post (50M inserts) is treated separately.

Storage

  • Post: ~1 KB with metadata (id, author, text, media ids, counters, timestamps). 60M posts/day × 1 KB ≈ 60 GB/day ≈ 110 TB over 5 years. Media is stored separately in object storage and dwarfs this.
  • Follow graph: 300M × 300 edges × 16 B (two 8-byte ids) ≈ 1.5 TB of edges. Fits a sharded KV or graph store; the hot part fits in memory.

Timeline cache

  • Keep the latest 800 post ids per active user: 800 × 8 B ≈ 6.4 KB. For 300M users ≈ 2 TB; only cache users active in the last ~30 days, say 150M → ~1 TB across a Redis cluster. Realistic and the central cost of the push model.

Bandwidth

  • A timeline page of 20 hydrated posts ≈ 20 KB. 35K reads/s × 20 KB ≈ 700 MB/s out of the API tier, excluding media served by the CDN.

API design

POST /v1/posts

  • Body: { text, mediaIds?, replyTo? }
  • Returns 201 { postId, createdAt }. The server assigns a time-ordered id (Snowflake) so ids double as a sort key.
  • Errors: 400 too long, 401, 429 rate limited.

GET /v1/feed/home?cursor=&limit=20

  • Returns { posts: [ { postId, author, text, media, counters, createdAt } ], nextCursor }
  • cursor is an opaque (postId) or (score, postId) pair; never an offset.

GET /v1/users/{id}/posts?cursor=&limit=20

  • The user timeline, served straight from the posts store by author.

POST /v1/users/{id}/follow and DELETE /v1/users/{id}/follow

  • Updates the graph and, optionally, backfills or trims the follower's cached timeline.

DELETE /v1/posts/{id}

Notes

  • Hydration is separate from the id list: the feed service fetches ids from the timeline cache and then batch-fetches post bodies and author profiles from their own caches.
  • Clients send the newest postId they have seen so the server can return "N new posts" cheaply.

Data model

Entity: post

  • post_id (PK, Snowflake: time-ordered 64-bit)
  • author_id (indexed)
  • text, media_ids[]
  • reply_to, created_at, deleted_at
  • counters (likes, reposts, replies) kept in a separate counter table or cache

Entity: follow

  • follower_id, followee_id (composite PK), created_at
  • Two access patterns: "who do I follow" (fan-in on read) and "who follows me" (fan-out on write). Store both directions: followers(followee_id → [follower_id]) and following(follower_id → [followee_id]).

Entity: user

  • user_id (PK), handle, display_name, avatar_url, follower_count, is_celebrity (derived)

Timeline cache (Redis)

  • Key home:{user_id} → sorted set of post_id scored by created_at, capped at 800 entries (ZADD + ZREMRANGEBYRANK).
  • Separate key per user is what makes fan-out writes cheap: one ZADD per follower.

Storage choice

  • Posts: wide-column store (Cassandra/Manhattan-style) partitioned by post_id, with a secondary table posts_by_author(author_id, post_id desc) for user timelines. Append-heavy, no joins, horizontal scale.
  • Graph: the same wide-column store with two tables, or a dedicated graph service (TAO/FlockDB-style) that fronts a sharded MySQL with memcache.
  • Counters: a separate counter service with Redis and periodic flush, never in the post row on the hot path.

High-level design

Write path (post)
1. Client calls POST /v1/posts via the load balancer to the post service.
2. The post service assigns a Snowflake id, writes the post to the posts store, and publishes a "post created" event to a queue. It returns 201 immediately.
3. Fan-out workers consume the event, look up the author's followers from the graph service, and ZADD the post id into each follower's home timeline cache. A worker processes followers in batches of a few thousand.
4. If the author is a celebrity (followers above a threshold), the workers skip fan-out; the post is only written to the posts store and to the author's own user timeline.

Read path (home timeline)
1. Client calls GET /v1/feed/home. The feed service reads the first page of ids from the user's timeline cache (ZREVRANGEBYSCORE with the cursor).
2. It fetches the latest post ids of the celebrities this user follows (a small list, from a per-celebrity cache) and merges them in by created_at.
3. The ids are hydrated: batch GET post bodies from the post cache (fallback to the posts store), batch GET author profiles from the user cache.
4. Optionally the ranking service re-orders the merged candidates. The page and a cursor are returned.

Why this split

  • Push makes reads a single cache range query. Pull for celebrities caps write amplification at the follower threshold. The merge step is the price of the hybrid, and it is cheap because the celebrity list per user is short.

Deep dives

1. Hybrid fan-out

  • Push (fan-out on write): on post, insert the id into every follower's cached timeline. Read is one ZREVRANGE. Cost is O(followers) writes per post.
  • Pull (fan-out on read): on read, fetch recent posts of every followee and merge. Cost is O(followees) reads per timeline view, which at 10 views/day/user dominates.
  • Hybrid: push for authors under the celebrity threshold (e.g. 10K–100K followers; tune by measuring fan-out lag), pull for the rest. At read time, merge the cached timeline with the last ~50 posts of each followed celebrity. Most users follow fewer than 20 celebrities, so the merge touches a handful of hot keys that are themselves cached.
  • Also skip pushing to inactive users (no login in 30 days); rebuild their timeline lazily on next login from posts_by_author of their followees.

2. Timeline cache structure and bounds

  • Redis sorted set per user: member = post_id, score = created_at (or just use the Snowflake id as both, since it is time-ordered).
  • Cap at 800 ids with ZREMRANGEBYRANK after each insert. 800 covers ~2 days of scrolling for a typical user; beyond that fall back to pull from the posts store.
  • Memory: 150M active users × ~6.4 KB ≈ 1 TB, sharded by user_id across a Redis cluster with replicas. A lost shard means timelines for those users are rebuilt lazily from the graph + posts_by_author (a cache miss, not data loss).

3. Fan-out pipeline and the celebrity threshold

  • Post service publishes {post_id, author_id, created_at} to a Kafka topic partitioned by author_id (keeps one author's posts in order).
  • Fan-out workers fetch the follower list in pages of 5K from the graph service and issue pipelined ZADDs. 50K followers ≈ 10 pages ≈ sub-second.
  • Define is_celebrity as follower_count > threshold, recomputed hourly. When an account crosses the threshold, future posts switch to pull; existing fanned-out ids stay in timelines (harmless, time-ordered).
  • Backpressure: if fan-out lag grows beyond a few seconds, shed load by deprioritising low-activity followers rather than dropping posts.

4. Ranking placement

  • Candidate generation = the merged list of up to ~500 recent ids from the cache + celebrity pull.
  • A ranking service scores candidates with lightweight features (recency, author affinity, engagement counters) in < 50 ms, then the top N are hydrated and returned. Heavy ML features are precomputed offline per (user, author) pair and cached.
  • Keep ranking after the cache, never inside fan-out: fan-out stores raw ids so a model change does not require rebuilding every timeline.

5. Deletes, edits and unfollows

  • Delete: mark deleted_at on the post; filter at hydration time (the id may still be in millions of sorted sets). A background job can ZREM from followers' timelines for celebrities-scale accounts, but filtering at read is the safety net.
  • Edit: post bodies are hydrated from the post cache by id, so an edit only invalidates one key.
  • Unfollow: ZREM that author's recent ids from the follower's timeline (fetch the author's last 800 ids from posts_by_author, ZREM in one pipeline). Also filter at hydration by checking the current following set for correctness.
  • Follow: optionally backfill the author's last ~20 posts into the follower's timeline so the feed changes immediately.

6. Pagination

  • Cursor = the smallest (score, post_id) on the page just returned. Next page = ZREVRANGEBYSCORE home:{uid} (score -inf LIMIT 0 20, plus the same cursor applied to the celebrity pull. Offsets are wrong under inserts; cursors are stable.
  • "New posts since": client sends its newest post_id; server does ZCOUNT above it.

7. Media and the graph store

  • Media uploads go directly to object storage via pre-signed URLs; the post stores media ids and the CDN serves bytes. The feed never touches media bytes.
  • Follow graph: two denormalised tables (followers, following) sharded by the first column, fronted by a cache. Follower lists for celebrities are paged, never loaded whole into one worker.

Trade-offs & wrap-up

Summary of decisions

  • Hybrid fan-out: push for normal users, pull for celebrities, merge at read.
  • Per-user timeline cache in Redis sorted sets, capped at 800 ids, rebuilt lazily on loss.
  • Posts in a wide-column store with a posts_by_author table; graph stored in both directions.
  • Snowflake ids as the sort key; cursor-based pagination.
  • Ranking sits after the cache on the read path.

What was given up

  • Freshness: a post reaches followers seconds later, not instantly.
  • Simplicity: two write paths and a merge step at read.
  • Exact correctness of cached timelines (deletes and unfollows are filtered at read).

With more time

  • Replies/threads, reposts and quote posts, notification fan-out, trends, real-time ("N new posts") push via WebSockets, ads and recommendation injection, abuse and spam filtering.