System Design: News Feed — How Twitter and Instagram Scale to Billions

System Design Interview Series — #5 of 15

Twitter serves 500M+ users
a personalised feed in
under 500 ms. The
engineering behind it
took a decade to perfect.

Scroll your Twitter timeline. Tap Instagram Stories. The feed appears in under a second — effortlessly. What’s invisible is one of the most difficult distributed-systems challenges in production engineering: delivering the right posts, from the right people, to 500 million users, faster than they can blink.

This post walks the news feed problem from naïve SQL all the way to the hybrid architecture that Twitter, Instagram, and Meta actually run. Each level exposes a real failure mode from the previous one — the same progression you need to demonstrate in a system-design interview.


1. The Core Problem

When Alice loads her feed she wants to see posts from everyone she follows, sorted newest-first, paginated. That requirement looks almost trivial — it isn’t.

Here is what “trivial” actually means at scale:

  • Alice follows 200 people on average
  • Those 200 people post at rates from once a week to once a minute
  • Her feed must load in under 500 ms — or she closes the app
  • Meanwhile, 500 million other Alices are doing the exact same thing simultaneously
  • Beyoncé just posted, which means 200 million feeds need updating right now

The fundamental tension is between read latency (how fast can Alice load her feed?) and write amplification (how much work does one post create across the system?). Every architectural decision you make is a negotiation between these two forces.


2. Requirements

Before designing anything, nail down the constraints. Interviewers reward explicit requirement scoping.

Functional requirements:

  • Users follow / unfollow other users
  • Users see a feed of posts from followed accounts, sorted reverse-chronologically
  • Feed supports infinite scroll (cursor-based pagination)
  • Posts include: text (up to 280 chars), optional media reference, timestamp, author

Non-functional requirements:

  • Feed load time: < 500 ms at p99
  • Scale: 500 M daily active users
  • Post ingestion: 1 B posts / day11,500 posts / sec
  • Read / write ratio: ~100:1 (heavily read-dominant)
  • Average follows per user: 200
  • 95th-percentile celebrity follower count: 50 M+
  • Availability: 99.99 % (under 1 hour downtime per year)
  • Eventual consistency acceptable — a few seconds of feed staleness is fine

Back-of-envelope to keep in mind:

Metric Calculation Result
Posts/sec 1 B / 86 400 ~11 500 / sec
Fan-out writes/sec (push) 11 500 × 200 avg followers ~2.3 M / sec
Feed reads/sec 500 M × 20 reads / day ~115 000 / sec
Post row size ~1 KB (text + metadata)
5-yr post storage 1 B × 365 × 5 × 1 KB ~1.8 PB
With 3× replication 1.8 PB × 3 ~5.4 PB

3. Level 1 — Naive Pull (Fan-out on Read)

The first instinct is to compute the feed dynamically at read time. When Alice requests her feed:

  1. Fetch Alice’s list of 200 followed user-IDs
  2. Query the posts table for those IDs in the last 24 hours
  3. Sort by timestamp, paginate to 20, return
SQL — naive feed query
-- Compute the feed on every single read request
SELECT
    p.id,
    p.author_id,
    p.content,
    p.created_at,
    p.media_url
FROM  posts p
WHERE p.author_id IN (
    SELECT followee_id
    FROM  follows
    WHERE follower_id = :alice_user_id
)
AND   p.created_at > NOW() - INTERVAL '24 hours'
ORDER BY p.created_at DESC
LIMIT 20;

Why this works (for a while): Simple, consistent, no pre-computation. Every read reflects the absolute latest data instantly.

Why this breaks at scale: The IN clause with 200 IDs can still be fast with indexes at low traffic. But the real problem is thundering herd: 115 000 feed reads per second all hitting the posts table with complex range scans. Even with read replicas, each query is expensive. When a celebrity posts to 50 M followers and everyone refreshes simultaneously, your database falls over.

Measured latency: 50–200 ms on a warm, lightly loaded database. 2–10 seconds under real production load. Unacceptable at scale.


4. Level 2 — Fan-out on Write (Push Model)

The insight: pre-compute the feed at write time. When a user posts, immediately push that post into the feed cache of every follower. Reads become a trivial cache lookup.

Write path: User Bob creates a post → post saved to DB → fan-out worker fetches Bob’s follower list → writes the post ID into a Redis sorted set for each follower.

Read path: Alice requests feed → read feed:{alice_id} from Redis → done.

Python — fan-out on write
# Write path: fan-out to each follower's sorted set
def fanout_post(post_id, author_id, timestamp):
    followers = get_followers(author_id)         # fetch follower IDs
    score     = timestamp                          # Unix ms as sort key

    for follower_id in followers:
        key = "feed:" + str(follower_id)
        redis.zadd(key, {post_id: score})        # O(log N) per write
        redis.zremrangebyrank(key, 0, -1001)    # keep latest 1000 only

# Read path: single Redis call, sub-millisecond
def get_feed(user_id, cursor=None, limit=20):
    key      = "feed:" + str(user_id)
    post_ids = redis.zrevrangebyscore(
                   key, cursor or "+inf", "-inf",
                   start=0, num=limit)
    return batch_fetch_posts(post_ids)           # mget from post cache

Why reads are blazing fast: Sub-millisecond sorted set range queries. No SQL joins, no table scans. A feed page loads in 1–5 ms including network.

The celebrity problem: Bob has 200 followers → 200 Redis writes. Fine. Beyoncé has 200 million followers. One tweet = 200 million Redis sorted-set writes. At 100 000 writes / second that takes 33 minutes. Early followers see the post; late followers wait half an hour. Completely unacceptable.

The celebrity problem is why the hybrid approach is necessary. The math simply does not work: 200 M writes × ~1 μs each = 200 s of pure single-threaded Redis time, ignoring network and coordination overhead.

This is the write amplification problem, and solving it is the central engineering challenge of every major news feed system.


5. Level 3 — Fan-out Visualizer

Watch write amplification in action. Click each user to simulate a post and observe how differently the system behaves based on follower count.

⚡ Fan-out on Write — Interactive Visualizer
Write amplification: 0 Redis writes
> Click a user to simulate posting...

6. Level 4 — The Hybrid Approach

The solution Twitter, Instagram, and Facebook converged on is a hybrid routing model: fan-out on write for regular users, fan-out on read for celebrities.

The routing rule:

Python — hybrid fan-out routing
# Threshold separating regular users from celebrities
CELEBRITY_THRESHOLD = 1_000_000

def handle_new_post(post, author):
    if author.follower_count < CELEBRITY_THRESHOLD:
        # Regular user: fan-out on write → push to Redis
        fanout_to_followers(post, author.follower_ids)
    else:
        # Celebrity: store in celebrity cache; merge at read time
        celebrity_cache.add_post(post)

def get_feed(user_id):
    # Step 1: Pre-computed feed from Redis (regular follows)
    regular_posts = redis.zrevrange("feed:" + user_id, 0, 49)

    # Step 2: Live pull for celebrity follows
    celeb_ids   = get_celebrity_followees(user_id)
    celeb_posts = celebrity_cache.get_recent(celeb_ids, limit=20)

    # Step 3: Merge, sort by score/time, paginate
    return merge_sorted(regular_posts, celeb_posts)[:20]

Trade-off matrix:

Approach Read latency Write amplification Celebrity problem Cache pressure
Fan-out on read 100–500 ms None ✓ Handled Low
Fan-out on write 1–5 ms O(followers) ❌ Catastrophic High
Hybrid 5–20 ms Low (skips celebrities) ✓ Handled Medium

The celebrity threshold (1 M) is configurable. Some systems use 100 K; Instagram reportedly adjusts it dynamically based on current system load. Users near the threshold may flip between paths as follower counts change — the system handles this gracefully because it queries both paths on every read regardless, deduplicating by post ID.


7. Level 5 — Feed Service Architecture

The complete system consists of cooperating services with clearly separated concerns. Click any node to learn its role, or highlight a data flow path using the legend.

🏗 Feed Service Architecture — click any node or path
ClientiOS / Android / Web
Post ServiceWrite API
Message QueueKafka
Fan-out WorkerRoutes push vs pull
Feed CacheRedis sorted sets
← push
pull →
Celebrity CacheRedis + CDN
Follow ServiceSocial graph
Feed Read ServiceMerge + Rank + Page
Ranking ServiceML scoring
Posts DBCassandra
User / Follow DBMySQL + graph cache
Media StoreS3 + CDN
Push path
Pull path
Read path
Clear
Click any node or legend item to explore the architecture...

8. Level 6 — Feed Ranking

Pure reverse-chronological order was Instagram’s original approach. After switching to a ranked feed, engagement increased by roughly 40 %. The tradeoff: ranked feeds are stickier but reduce serendipity and can create filter bubbles.

Why ranking beats chronological:

  • A celebrity’s low-quality retweet shouldn’t displace a close friend’s life milestone
  • Viral content from yesterday is often more interesting than a routine post from 30 seconds ago
  • Different users have different engagement patterns — personalisation increases time-in-app

Scoring model:

Python — simplified feed ranking score
import math, time

def score_post(post, user):
    # Recency: exponential decay, half-life = 6 hours
    age_hrs  = (time.time() - post.created_at) / 3600
    recency  = math.exp(-0.693 * age_hrs / 6.0)   # [0.0, 1.0]

    # Engagement: log-scaled, comments/shares weighted higher
    raw_eng  = post.likes + post.comments * 3 + post.shares * 5
    engage   = math.log1p(raw_eng) / 20.0              # normalised [0.0, ~1.0]

    # Relationship strength: how often user has interacted with author
    rel      = user.interaction_score(post.author_id)   # [0.0, 1.0]

    # Content-type affinity: user preference for photo / video / text
    affinity = user.content_pref(post.media_type)       # [0.0, 1.0]

    # Weighted combination (weights learned by gradient boosting)
    score = (0.35 * recency
           + 0.25 * engage
           + 0.25 * rel
           + 0.15 * affinity)
    return score

In production, the scoring function is a trained ML model — typically a gradient-boosted tree or a two-tower neural network. The features above are inputs; the model weights are learned from implicit feedback signals: did the user like it, comment, share, or scroll past in under 0.3 seconds (a strong negative signal)?

Where ranking runs: Ranking scores are computed in the Feed Read Service after candidate retrieval, before the final sort-and-paginate step. Ranking the entire Redis sorted set would be too slow; in practice only the top ~200 candidates from the cache are scored, then the top 20 are returned.


9. Level 7 — Pagination

You cannot paginate a live feed with page numbers. Here is why.

Alice opens her feed and sees posts 1–20. She scrolls. While she reads, 5 new posts arrive at the top. She requests “page 2.” With offset-based pagination:

SQL — why OFFSET breaks on live feeds
-- Page 2 with offset pagination (WRONG for live feeds)
SELECT * FROM feed_posts
WHERE  user_id = :uid
ORDER BY score DESC
LIMIT 20 OFFSET 20;

-- The 5 new posts at the top shifted everything down by 5.
-- Alice sees posts 16–20 again (duplicates).
-- Posts 21–25 are silently skipped (missing content).
-- Page 2 of a live feed is a DIFFERENT page 2 every time.

Cursor-based pagination solves this. The cursor encodes the position of the last seen item — new posts at the top do not affect it.

Python — cursor-based feed pagination
def get_feed_page(user_id, cursor=None, limit=20):
    if cursor is None:
        # First page: get most recent posts
        posts = redis.zrevrangebyscore(
            "feed:" + user_id, "+inf", "-inf",
            start=0, num=limit
        )
    else:
        # Subsequent page: posts with score STRICTLY LESS THAN cursor
        # "(" prefix in Redis means exclusive bound
        posts = redis.zrevrangebyscore(
            "feed:" + user_id,
            "(" + str(cursor),   # exclusive lower-than-cursor
            "-inf",
            start=0, num=limit
        )

    next_cursor = posts[-1].score if posts else None
    return {"posts": posts, "next_cursor": next_cursor}

Cursor-based pagination isn’t just better for feeds — it’s essential. Page 2 of a real-time feed is a different page 2 every time you load it. Offset-based pagination is a correctness bug, not just a performance issue.

The cursor is opaque to the client — it sends back whatever the server gave it. Internally it can be a Unix millisecond timestamp, a post ID, or a composite (timestamp + post ID to handle ties). For ranked feeds, the cursor must encode the ranking score, not just timestamp, to prevent items re-appearing or disappearing as scores change during a scroll session.


10. The Feed Simulator

Follow users, generate posts, and toggle between push and pull models to see how the system behaves differently. Notice that with the push model, only followed-user posts pre-populate your feed — unfollowing removes them on the next refresh (not immediately, a known trade-off).

📰 Feed Simulator — follow users, post, toggle models
Model:
Users — click to Follow / Unfollow
Simulate
Your Feed
⚡ Push — feed pre-computed in Redis
Follow some users then generate posts

11. Storage Estimation

No system design answer is complete without storage math.

Post row schema:

SQL — posts table schema
CREATE TABLE posts (
    post_id        BIGINT       NOT NULL,  -- 8 B  (Snowflake ID encodes time + node)
    author_id      BIGINT       NOT NULL,  -- 8 B
    content        VARCHAR(280) NOT NULL,  -- avg ~140 B UTF-8
    media_url      VARCHAR(256),            -- 256 B nullable
    created_at     BIGINT       NOT NULL,  -- 8 B  Unix ms
    likes_count    INT          DEFAULT 0, -- 4 B
    comments_count INT          DEFAULT 0, -- 4 B
    metadata       JSONB,                   -- ~200 B avg (hashtags, mentions, geo)
    PRIMARY KEY (author_id, post_id)        -- Cassandra: partition by author
);
-- Estimated row size: ~850 B raw ≈ 1 KB with Cassandra overhead + secondary indexes

Volume calculations:

Component Calculation Size
Posts raw 1 B/day × 365 × 5 yr × 1 KB ~1.8 PB
Posts with replication (×3) 1.8 PB × 3 ~5.4 PB
Redis feed cache 500 M users × 1 000 post-IDs × 8 B ~4 TB
Redis active only (20 % DAU) 4 TB × 0.20 ~800 GB
Follow graph 500 M × 200 follows × 8 B ~800 GB
Media (images, 20 % of posts) 200 M/day × 500 KB ~100 TB/day
Media CDN origin (5 yr) 100 TB × 365 × 5 ~183 PB

Ingestion bandwidth:

  • Posts DB write: 11 500 / sec × 1 KB = 11.5 MB / sec — comfortable for a 20-node Cassandra cluster
  • Fan-out writes: 2.3 M Redis ops / sec — requires a Redis cluster of ~15–20 shards
  • Media ingest: 100 TB / day = ~1.2 GB / sec — handled by S3 multipart upload with transfer acceleration

Feed cache sizing: 4 TB is the theoretical max. In practice, LRU eviction is applied to users inactive for more than 30 days. Instagram reported ~60 % of users are DAU, so the working set fits in roughly 2.4 TB of Redis RAM — about 20 nodes at 128 GB each, leaving headroom for replication.


12. Key Takeaways

Walking all seven levels, the core principles are clear.

Read / write tension is the whole game. Every news-feed decision is a negotiation: faster reads require more write work. The hybrid model is the industry consensus because it optimises for the common case (regular users, cheap fan-out) while isolating the edge case (celebrities, skip fan-out).

Architecture evolves with load. The naïve SQL approach works at 10 K users. Fan-out on write works at 1 M users. The hybrid is necessary at 100 M+. There is no universally correct architecture — only architectures appropriate for a given load.

Cursor pagination is non-negotiable. On any feed with live updates, offset-based pagination produces incorrect results. This is a correctness requirement, not a performance optimisation.

Decouple writes from side-effects. The Kafka layer between Post Service and Fan-out Worker makes the system resilient to worker outages without affecting write availability. Post creation returns in < 5 ms; fan-out completes asynchronously.

Store IDs in caches, not documents. The Redis feed cache holds post IDs, not full post content. This means posts can be edited or deleted without invalidating feed caches — the downstream batch fetch always retrieves the current version. Cache invalidation becomes trivial.

Twitter’s original architecture used fan-out on write exclusively — until Justin Bieber joined. His first tweet triggered fan-out to millions of followers simultaneously, saturating all fan-out workers. The “Bieber problem” forced Twitter to build the hybrid architecture now used industry-wide.

Interview checklist:

  1. Scope requirements: DAU, posts/sec, read/write ratio, follower distribution
  2. Naive pull → identify thundering-herd DB bottleneck
  3. Fan-out on write → identify celebrity write-amplification problem
  4. Hybrid routing → explain the threshold and merge strategy
  5. Draw full architecture: Post Service → Kafka → Fan-out Worker → Redis / Celebrity Cache → Feed Read Service
  6. Cursor-based pagination — explain why OFFSET is wrong
  7. Ranking as an enhancement — recency decay + engagement + relationship strength
  8. Back-of-envelope: storage, bandwidth, Redis cluster sizing

Demonstrate that progression coherently and you have answered one of the most common distributed-systems questions at senior-level engineering interviews.


Next in the series: #6 — Design a Distributed Cache — from a single Redis node to a globally consistent multi-region caching layer.