shreyansh

News Feed

30-Second Glance

  • What: Personalised home feed ("posts from people/pages you follow, ranked") with <200 ms p99 reads and ~5 s post-to-follower visibility.
  • Scale: ~100M posts/day fanned to avg 200 followers ⇒ ~20B deliveries/day (~230k/s); feed caches sized for active users only.
  • Core trick: Hybrid fan-out — push for normal users, pull for celebrities; 3 cache tiers (feed-ids / post-bodies / graph).
  • Hardest part: The celebrity (Justin Bieber) problem; ranking + seen-filtering; cold start and thundering herds.

1. Requirements

Functional:

  1. Publish post (text + media refs + visibility).
  2. getFeed(user, cursor, limit) — ranked, paginated, no duplicates across pages.
  3. Follow/unfollow; feed reflects graph changes within ~1 min.
  4. Seen-filtering: don't reshow recently seen posts.
  5. Like/comment counts displayed inline (approx ok).

Non-functional:

Requirement Target
Feed read p99 < 200 ms
Post-to-follower visibility p50 < 5 s (celebrity pull path < 30 s staleness)
Availability 99.9% reads; post-accept 99.9% (async fan-out may lag)
Consistency Eventual — friends may see a post seconds apart; no time-travel (monotonic per viewer)

Out of scope:

  • Full-text search over posts.
  • Stories/ephemeral (see Instagram case).
  • Messaging/notifications fan-out (separate systems, shared primitives).

2. Capacity Estimation

Assumptions: 500M users, 300M DAU; 100M posts/day (0.2/user/day blended); avg 200 followers; feed page = 20 posts; avg user opens feed 5×/day.

Metric Math Result
Post writes 100M / 86400 ~1.2k/s avg, ~5k/s peak
Fan-out deliveries (pure push) 100M × 200 / 86400 ~230k/s avg — the number that kills naive push
Feed reads 300M × 5 / 86400 ~17k/s avg, ~50k/s peak
Feed-cache RAM (active only) 50M cached users × 500 ids × 64 B ~1.6 TB across Redis Cluster (NOT all 300M DAU — LRU + TTL)
Post-body store 100M/day × 2 KB (text + meta; media in blob store) ~200 GB/day → ~70 TB/year in Cassandra

3. API Design

POST /api/v1/posts
{ "text": "hello", "media_ids": ["m_9f2"], "visibility": "public" }
201 { "post_id": "p_71xQ2", "created_at": "..." }

GET /api/v1/feed?limit=20&cursor=eyJzY29yZSI6... 
200 { "items": [{ "post_id": "p_71xQ2", "text": "...",
                 "author": {...}, "like_count": 412, "score": 0.87 }],
      "next_cursor": "eyJ..." }

POST /api/v1/follow/{userId}      // 204; feed backfill async
GET  /api/v1/posts/{postId}       // single post (cacheable, public CDN)
Endpoint Purpose Notes
POST /api/v1/posts Publish Returns immediately; fan-out async → 202 semantics with 201 + fanout: pending
GET /api/v1/feed Ranked page Opaque cursor (score,id), never offset (insertions would dup/shift); If-None-Match for poll
follow/unfollow Graph mutation Triggers async feed patch (backfill recent posts / tombstone)
like/comment Engagement Counter increments via Kafka (approx counts ok)

4. Data Model & Storage

Entity Store Partition / shard key Why this store
Post bodies Cassandra post_id (time-UUID) Write-heavy, immutable-ish, hash-distributed; TTL optional
Per-user feed (materialised id lists) Redis Cluster (list/zset of post-ids, capped ~1000) user_id O(1) page assembly; LRU evicts idle users
Social graph (followees/followers) Graph store / sharded MySQL adjacency user_id Follow-list reads on write path; follower-list reads for fan-out
Media blobs Object storage (S3) + CDN media_id Bytes don't belong in Cassandra (see Instagram case)
Engagement counters Redis (hot) → Cassandra (durable) post_id Approx counts fine (Leaderless Replication-tolerant)
-- Cassandra
CREATE TABLE posts_by_id (post_id TIMEUUID PRIMARY KEY, author_id BIGINT,
  text TEXT, media_ids LIST<TEXT>, created_at TIMESTAMP);
-- Redis per user: feed:{user_id} -> ZSET (score=rank, member=post_id), MAXLEN ~1000, TTL 7d
-- Graph: follows (user_id, followee_id, created_at) PK(user_id, followee_id)
  • Feed entries store ids only — bodies hydrated from post-cache/Cassandra in a parallel MGET. Editing a post updates one row, not 200M feed copies.
  • Shard feeds by hash(user_id); celebrity follower lists paginated (never load 100M rows into one worker — shard the fan-out job itself).

5. High-Level Design

Write path: post → API → post store + media refs → fan-out decider → (normal: queue → fan-out workers → push id into followers' Redis zsets) / (celebrity: mark id in celebrity-outbox only). Read path: GET /feed → Redis zset page → hydrate bodies (post-cache → Cassandra) → merge celebrity outboxes (pull) → rank/seen-filter → cursor page.

loading diagram…
  • Post service: validation, media-ref attach, writes canonical post row.
  • Fan-out decider + queue (Kafka): splits normal vs celebrity; backpressure buffer (lag metric = health signal).
  • Fan-out workers (stateless, sharded by follower-range): push ids; idempotent (ZADD NX) so retries are safe.
  • Cache tiers: L1 feed-id zsets (Redis) → L2 post bodies (Redis/memcached) → L3 graph (Redis + DB).
  • Read path ranker: merges push ids + celebrity pulls + ads, applies seen-filter (Bloom/Roaring bitmap per user), sorts, slices cursor page.

6. Deep Dives

6.1 Fan-out-on-write vs read vs hybrid (the celebrity problem)

Approach Pros Cons Use when
Push (fan-out-on-write) Reads O(1); trivial ranking precompute 100M-follower post = 100M writes; hot author kills workers Normal users (≤ ~10k followers)
Pull (fan-out-on-read) Zero write amplification Every read fans across followees (200 ○ latency); rank at read time Celebrities; also new follows (backfill)
Hybrid (chosen) Bounded write cost + fast reads Two code paths; celebrity posts slightly staler Production default

Threshold (e.g. followers > 10k–100k, tuned): author's posts skip push and land in a per-celebrity outbox (Redis list, capped ~3k). Read path pulls outboxes of the celebrities you follow (usually <50) and merges with your pushed zset. Justin Bieber posting costs 1 write however many followers he has; his followers pay a bounded merge. Follow/unfollow: async patch — unfollow tombstones the id from your zset; new follow triggers a backfill job (top recent ids from followee + outbox).

6.2 Ranking + seen-filtering + pagination that doesn't lie

  • Pipeline per page request: candidate fetch (zset range + celebrity pulls + ads slot) → hydrate → filter (seen set, muted, visibility) → score (recency_decay × affinity × quality, weights from offline training) → sort → slice → cursor.
  • Seen-filter: per-user Roaring bitmap / Redis set of last ~5k seen ids, 30-day TTL. Checked before scoring to save hydration cost.
  • Cursors encode (score, post_id) — stable across concurrent inserts (offset would duplicate/shift). Monotonicity guard: never surface a post older than the newest already-seen id that outranks it ("no time-travel").
  • Cold start (new user / cache miss): synchronous pull path builds first page from followees' recent posts (slow once, ~500 ms), then materialises the zset for next time.

6.3 Cache tiers, stampedes, and failure modes

  • L1 feed zset: TTL 7 d + LRU; L2 post body: write-through on publish, 24 h TTL; L3 graph: 60 s TTL with version stamp (follow changes must propagate fast).
  • Stampede guards: singleflight on zset rebuild; jittered TTLs; stale-while-revalidate (serve slightly old page while rebuilding in background).
  • If Redis L1 is down: degrade to pull-only (slower, correct) — never fail the read. If Kafka lags: reads still serve (stale by lag seconds); alert on lag, not on errors.
  • Like/comment counts: Redis INCR on hot posts, flushed to Cassandra every ~10 s; a lost increment is invisible — by design.

7. Bottlenecks, Trade-offs & Monitoring

First bottleneck at 10x: fan-out worker throughput during global events (everyone posts at once) → autoscale workers on queue lag + shed celebrity-adjacent work first (their pull path covers it) + cap per-author push rate. Next: Redis memory for feed zsets → tighten cap 1000→500 + shorter TTL for dormant users.

Approach Pros Cons Use when
Pure push Fastest reads Celebrity write storms Small networks
Pure pull No amplification Slow reads, complex ranking Read-rare systems
Hybrid (chosen) Bounded both sides Two paths to maintain Any real social feed
Metric Alert if
Feed read p99 > 300 ms for 5 min
Fan-out Kafka lag > 60 s (visibility SLA burning)
Celebrity-merge p99 > 100 ms (outbox too big — lower cap)
L1 hit ratio < 80%
Duplicate-across-pages complaints / cursor errors > baseline (pagination bug)

Interview one-liners

  • Q: Push or pull? / A: Hybrid — push for normal authors (fast reads), pull for celebrities (bounded writes); threshold on follower count.
  • Q: Why store only ids in the feed cache? / A: Edits touch one row, not millions of feed copies; hydration is a parallel MGET.
  • Q: Never use OFFSET — why? / A: Concurrent inserts shift pages; cursor on (score,id) is stable.