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:
- Publish post (text + media refs + visibility).
getFeed(user, cursor, limit)— ranked, paginated, no duplicates across pages.- Follow/unfollow; feed reflects graph changes within ~1 min.
- Seen-filtering: don't reshow recently seen posts.
- 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.
- 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
INCRon 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.
Related
- Twitter Timeline case — builds on these patterns for home vs user timelines
- Instagram case — media blobs + stories TTL on top of this feed
- Cache Patterns and Eviction — three cache tiers, stampede guards
- Partitioning — feed/post sharding
- Stream Processing — fan-out queue + engagement counters
- Leaderless Replication / Quorums — approximate counters reasoning