TL;DR

1. The problem: why is the feed so hard?

When you open Instagram, the first thing you see is your feed: a scrolling list of photos, carousels and videos from accounts you follow (plus suggested posts and ads). It looks simple. Under the hood it is one of the hardest problems in the whole system, for three reasons:

The naive solution — "run a SQL query: select posts where author is in (people I follow) order by time desc limit 20" — works for a startup with 10,000 users. At Instagram scale the "people I follow" list spans hundreds of database shards, the query runs hundreds of thousands of times per second, and the result still has to be ranked. This lesson walks through how to make it fast, cheap and robust.

🧠 Analogy Imagine you subscribe to 300 newsletters. Pull is like visiting 300 publishers' offices every morning and collecting whatever they printed, then sorting the pile yourself — slow for you, zero effort for the publishers. Push is like each publisher dropping a copy into your personal mailbox the moment it's printed — your morning is instant, but a publisher with 100 million subscribers has to stuff 100 million mailboxes for every issue. The hybrid is what sensible people do: small publishers mail you; for the few giant magazines you just glance at the newsstand when you go out. And a ranked feed is a personal assistant who reads the whole pile and puts the five things you'll care about most on top.

2. Requirements and back-of-the-envelope numbers

Always start a feed design by pinning down scope (see Requirements).

2.1 Functional requirements

2.2 Non-functional requirements

2.3 Rough numbers (assumptions, not official statistics)

Let's do the math (method from Back-of-the-Envelope Estimation):

Assume 500M daily active users (DAU)
Each DAU loads ~20 feed pages/day (several sessions × a few pages)
  → 500M × 20 = 10B feed requests/day
  → 10B / 86,400 s ≈ 115,000 feed requests/s average, ~250-350k/s at peak

Assume 100M new feed posts/day
  → 100M / 86,400 ≈ 1,150 posts/s average (~3k/s peak)

Assume a post's author has, on average, ~300 followers (weighted by posting volume)
  → 1,150 posts/s × 300 = ~345,000 timeline inserts/s if we push to everyone
  → if we skip followers inactive for 30+ days (say half), ~170,000 inserts/s

Read:write ratio for the feed ≈ 115,000 / 1,150 ≈ 100 : 1

Two observations drive the entire design. First, reads dwarf writes, so we should do work at write time to make reads cheap. Second, the average hides a brutal tail: one celebrity post can generate more fan-out work than a million ordinary posts. Keep both in mind.

3. The two fundamental approaches

The key term is fan-out: delivering one item to many recipients (one post → many followers' feeds). The big design decision is when fan-out happens.

Fan-out-on-READ (pull) Fan-out-on-WRITE (push) 📱 Viewer opens feed News Feed Servicemerge k sorted lists Alicerecent posts Bobrecent posts Carolrecent posts … ×500followees Write = 1 insert into author's list (cheap) Read = k queries + merge, on every open 📱 Author posts Fan-out workersconsume from Kafka Timeline: BobRedis ZSET Timeline: DanRedis ZSET … ×N followersRedis ZSET Write = N inserts (N = follower count) Read = 1 lookup of my timeline (cheap)
Pull does the work when a viewer reads; push does the work when an author writes. The cost moves, it never disappears.

3.1 Fan-out-on-read (the pull model)

In the pull model nothing special happens when a post is created: the post is written once to the post store (in Instagram's diagram, Cassandra sharded by user_id, so each author's posts sit together, sorted by time). All the work happens when somebody reads their feed:

  1. Ask the Follow/Graph Service: "whom does viewer V follow?" → a list of, say, k = 500 account IDs.
  2. For each followee, fetch their most recent posts (e.g. the last 10–20 post IDs with timestamps). Each followee's list is already sorted newest-first.
  3. Merge the k sorted lists into one sorted list and take the top L (say 20–500 for ranking).

Step 3 is the classic k-way merge (the same algorithm as merging sorted runs in external sorting or LSM-tree compaction, see Indexing). You keep a min/max-heap holding the head of each list; repeatedly pop the newest item and push the next item from the same list:

import heapq

def merge_feeds(lists, limit):
    """lists: {author_id: [post, post, ...]} each sorted newest-first."""
    heap = []                                   # max-heap via negated timestamps
    for author, posts in lists.items():
        if posts:
            heapq.heappush(heap, (-posts[0].ts, author, 0))
    out = []
    while heap and len(out) < limit:
        neg_ts, author, i = heapq.heappop(heap)  # newest remaining post overall
        out.append(lists[author][i])
        if i + 1 < len(lists[author]):
            nxt = lists[author][i + 1]
            heapq.heappush(heap, (-nxt.ts, author, i + 1))
    return out
# Cost: O(k) to build the heap + O(L log k) to emit L posts.

The CPU cost of the merge is trivial (k = 500, L = 50 → a few thousand comparisons). The real cost is I/O and fan-in:

Cost of one pull-based feed readEstimate (k = 500 followees)
Follow list lookup1 call (cached)
"Recent posts" lookups500 keys, spread over many shards — maybe 50–200 parallel RPCs after batching per shard
Data read500 × 20 IDs = 10,000 (id, ts) pairs to pick 50
Cluster-wide load115,000 reads/s × 500 = ~57 million list lookups per second
Tail latencyThe request is as slow as the slowest of its ~100 parallel calls

That last row is the killer. If each shard call has a 1% chance of exceeding its p99 latency of 20 ms, then a request that waits for 100 independent calls exceeds 20 ms with probability 1 − 0.99100 ≈ 63%. Fan-in turns every backend's rare slow response into the feed's common slow response — the "tail at scale" problem. You can mitigate it with hedged requests and timeouts that drop slow shards, but you are fighting physics on every single read.

Where pull shines: writes are O(1); there is no wasted work for inactive users; unfollow and delete are trivially correct (the next read simply doesn't see them); and the feed is always perfectly fresh. Facebook's original News Feed infrastructure ("Multifeed", described in public talks) was essentially pull-based: per-user activity is stored in leaf servers and aggregator servers query and merge them at read time — Facebook could afford it with huge in-memory leaf tiers.

3.2 Fan-out-on-write (the push model)

The push model flips the cost. Every user has a precomputed, materialised timeline — a list of post IDs that should appear in their feed. When someone posts, the post is fanned out to every follower's timeline:

  1. The Upload Service stores the post and publishes a post.created event to Kafka, then immediately returns success to the author.
  2. Fan-out workers consume the event, page through the author's follower list from the graph store (e.g. 1,000 follower IDs per page), and for each follower insert the post ID into timeline:{follower_id} in the Redis feed cache.
  3. When a follower opens the app, the Feed Service reads the top of their own timeline — one key, one shard, one round trip.

Reads become trivially cheap, which is exactly what a 100:1 read-heavy system wants. The price is write amplification: one logical write (a post) turns into N physical writes (N = number of followers). Let's put numbers on it.

3.3 Write amplification: 1,000 vs 100,000,000 followers

Regular user (1,000 followers)Celebrity (100,000,000 followers)
Timeline inserts per post1,000100,000,000
Follower-list pages to read (1k IDs/page)1100,000
Redis round trips (pipelined ~100 ops per shard batch)~10–20~1,000,000
Time to complete with a fan-out budget of 1M inserts/s for this post~1 ms of work; visible in followers' feeds within a second~100 s — and it competes with every other post in the queue
Memory (~64 bytes per sorted-set entry)64 KB~6.4 GB for one post ID copied 100M times
Wasted workSmallHuge: tens of millions of those followers won't open the app before the entry is trimmed away

If a celebrity posts 5 times a day, that's 500M inserts — more than the entire rest of a mid-sized platform. Worse, Kafka partitions are processed in order, so ordinary posts queued behind a celebrity's fan-out are delayed: your best friend's post shows up two minutes late because a pop star posted first. This is called the celebrity problem or hot-key / "Justin Bieber" problem, and it is why pure push does not work at Instagram scale.

3.4 Side-by-side comparison

DimensionFan-out-on-read (pull)Fan-out-on-write (push)
Write costO(1) — store the post onceO(followers) — one insert per follower
Read costO(followees) lookups + merge on every readO(1) — read one precomputed list
Read latencyHigh and spiky (tail of many parallel calls)Low and predictable
FreshnessPerfect — computed at read timeLag of seconds (more for big fan-outs)
StorageMinimalOne timeline per user (IDs only)
Wasted workNone for inactive usersPushing to users who never come back
CelebritiesFine — reading one hot list is cacheableCatastrophic write storms
Unfollow / deleteAutomatically correctMust clean timelines or filter at read time
Best whenFew readers, many writers; or followee counts smallRead-heavy, bounded follower counts

4. The hybrid model: push for most, pull for celebrities

The observation that saves us: the two models fail in opposite places. Push fails for authors with huge follower counts; pull fails for viewers who follow many accounts. But a typical viewer follows only a handful of celebrities. So:

📱 Regular authorbelow threshold Kafkapost.created Fan-out workerspush to active followers Redis timelinesone ZSET per follower 📱 Celebrity authorabove threshold, e.g. 1M+ Celebrity recent postsone small list per author (hot) 📱 Vieweropens the app News Feed Servicemerge → rank → hydrate no fan-out GET /feed ② pull celebs ① read my timeline
Hybrid fan-out: regular posts are pushed into timelines ahead of time; celebrity posts are pulled at read time and merged in.

4.1 The hybrid read, step by step

def candidate_ids(viewer_id, since_cursor):
    # ① precomputed part: one Redis call
    pushed = redis.zrevrangebyscore(f"timeline:{viewer_id}", "+inf", since_cursor, limit=500)

    # ② pulled part: the few celebrities this viewer follows (cached list, ~0-30 IDs)
    celebs = graph.followed_celebrities(viewer_id)
    pulled = recent_posts_cache.multi_get(celebs)       # each list small & very hot

    # ③ k-way merge with small k, then hand to ranking
    return merge_feeds({"pushed": pushed, **pulled}, limit=500)

Why is ② cheap even though a celebrity's recent-posts list is read by tens of millions of viewers? Because it is the same small object for everyone, so it caches beautifully — replicated across many cache servers or even held in each Feed Service instance's local memory for a few seconds. A hot read key is easy to scale with replicas (see Caching: hot keys); a hot write fan-out is not.

4.2 Choosing and applying the threshold

4.3 Skipping inactive users

The second big optimisation: don't push to people who aren't going to look. A large fraction of any account's followers haven't opened the app in weeks. Fan-out workers check a cheap "last active" signal (a bitmap or a small key per user, updated on login) and skip anyone inactive for, say, 7–30 days. Their timelines are not maintained; if they come back, the timeline is rebuilt on demand by pulling (section 10).

Effects: if half of all follower edges point at inactive accounts, this alone halves fan-out writes and timeline memory. A further refinement is prioritised fan-out: push first to followers who are online right now or very active, then to the rest, so the people most likely to see the post get it fastest.

💡 The general principle Precompute for the common case, compute on demand for the expensive edge cases. Push is precomputation; pull is on-demand computation. The hybrid applies each where its cost is lowest — a pattern you'll see again in Stories (tray precomputation) and Notifications (celebrity fan-out throttling).

5. The timeline cache: data structure and commands

In Instagram's architecture diagram, timelines live in the Feed Cache (Redis). Each user's timeline is a single key holding post IDs only — never full post objects. This keeps entries tiny, and means an edited caption or an updated like count never needs to be propagated to millions of timeline copies: the ID is resolved to fresh data at read time (hydration, section 7).

5.1 Sorted set vs list

Redis typeHow it's usedProsCons
Sorted set (ZSET) ⭐member = post ID, score = post timestamp (ms)Always ordered regardless of arrival order (Kafka retries, out-of-order fan-out); idempotent (re-adding the same ID is a no-op → safe at-least-once processing); range queries by score = natural cursor pagination; easy removal of a specific post~50–70 bytes per entry (skiplist + hash table overhead)
ListLPUSH new IDs, LTRIM to capCompact, O(1) pushOrder = arrival order; duplicates possible on retries; removing a deleted post is O(n)
Packed binary blobFixed-width 8-byte IDs in one string, rewritten on updateVery compact: 500 × 8 B = 4 KBRead-modify-write on every insert; needs custom code

5.2 Example commands

# Fan-out worker: add post 3170538912345678 (created at 1730000000123 ms) to follower 42's timeline
ZADD    timeline:42  1730000000123  3170538912345678
# Cap the timeline at the newest 500 entries (remove ranks 0..-501 = everything older)
ZREMRANGEBYRANK timeline:42  0  -501
# Keep the key alive while the user is active (14 days)
EXPIRE  timeline:42  1209600

# Feed read, first page: newest 50 post IDs with their scores
ZREVRANGEBYSCORE timeline:42  +inf  -inf  WITHSCORES  LIMIT 0 50
# Next page: strictly older than the last score we returned (the "(" means exclusive)
ZREVRANGEBYSCORE timeline:42  (1729999000456  -inf  WITHSCORES  LIMIT 0 50

# Author deleted post 3170538912345678 → best-effort cleanup (read-time filter is the real guarantee)
ZREM    timeline:42  3170538912345678

Workers send these three write commands together in a pipeline (or a small Lua script so add + trim + expire are atomic), and batch many followers that live on the same Redis shard into one round trip. Timelines are distributed across a Redis cluster by hashing the key (consistent hashing / hash slots), so different users' timelines land on different shards and the load spreads evenly.

⚠️ Subtle pitfall: 64-bit IDs as scores It's tempting to use the post ID itself as the score, because Instagram-style IDs are time-sortable (the first 41 bits are a millisecond timestamp — see ID generation). But Redis scores are IEEE-754 doubles with only 53 bits of precision, so two 64-bit IDs created in the same millisecond can round to the same score and lose their exact order. Use the millisecond timestamp (which fits in 53 bits) as the score and the full ID as the member; ties are broken by member ordering.

5.3 How big is the cache?

Assume 500M users with maintained timelines (active in the last ~2 weeks)
Cap = 500 entries per timeline, ~64 bytes per ZSET entry
  → 500 × 64 B = 32 KB per user
  → 500M × 32 KB = 16 TB of RAM
  → ~250 Redis nodes with 64 GB usable each, ×2-3 for replicas

Same data as packed 8-byte IDs + 4-byte timestamps:
  → 500 × 12 B = 6 KB per user → 3 TB total

16 TB of RAM is expensive but entirely normal for a company of this size, and the encoding optimisation shows how much headroom exists. Two levers keep it bounded: the cap (nobody scrolls 500 posts deep; older content can be fetched by pulling) and the TTL (inactive users' timelines evaporate and are rebuilt if they return).

6. The write path, end to end

Here is the full lifecycle of a post from "Share" to "it's in your followers' timelines":

 Author app    API GW    Upload Svc    Post store    Kafka      Fan-out worker    Graph svc     Redis timelines
     |  POST /posts |          |            |           |               |               |                |
     |------------->|--------->|            |           |               |               |                |
     |              |          |-- write -->|           |               |               |                |
     |              |          |  (post row, media refs)|               |               |                |
     |              |          |-- publish post.created(post_id, author_id, ts) --->   |                |
     |  201 Created |<---------|            |           |               |               |                |
     |<-------------|          |            |           |-- consume --->|               |                |
     |  (author sees post instantly via optimistic UI)  |               |               |                |
     |              |          |            |           |               |-- follower_count(author) ----> |
     |              |          |            |           |               |   > threshold? stop: celebrity |
     |              |          |            |           |               |   (pulled at read time)        |
     |              |          |            |           |               |-- followers(author, page 1) ->|
     |              |          |            |           |               |<-- 1,000 follower IDs --------|
     |              |          |            |           |               |  drop inactive followers       |
     |              |          |            |           |               |  group the rest by Redis shard |
     |              |          |            |           |               |-- pipeline per shard: ZADD +   |
     |              |          |            |           |               |   ZREMRANGEBYRANK + EXPIRE --->|
     |              |          |            |           |               |  ... next page until done ...  |
     |              |          |            |           |               |-- commit Kafka offset          |

Key properties of this design:

6.1 Unfollow, block, delete and privacy changes

Timelines are a cache of a derived view, so they will occasionally contain IDs that should no longer be shown. Two layers keep the feed correct:

  1. Best-effort cleanup: on unfollow, an async job removes that author's IDs from the viewer's timeline; on delete, a job can ZREM the post from followers' timelines (or simply skip it — see next point).
  2. Read-time filtering (the real guarantee): during hydration the Feed Service drops any post that is deleted, from an author the viewer no longer follows or has blocked, or from a now-private account. Because this check runs on every read, correctness does not depend on cleanup jobs finishing.

7. The read path: from post IDs to a rendered feed

Getting candidate IDs is only the first step. A feed request goes through four stages: candidates → hydration → ranking → pagination/blending.

📱 AppGET /feed?cursor=…
→auth, rate limit
API Gateway
→
Feed Servicecandidates
→features
RankingML models
→top N
Hydrationmulti-get
→JSON + CDN URLs
📱 Render

7.1 Hydration: turning IDs into posts

Hydration means filling in a list of bare IDs with everything the app needs to draw each post. For a page of 20 posts the Feed Service needs, per post:

DataSourceHow it's fetched
Post metadata (caption, media keys, type, location, created_at)Post store (Cassandra) behind a cacheOne multi-get for all 20 IDs
Author profile (username, avatar URL, verified badge)User service / User DB behind a cacheMulti-get of the distinct author IDs (often fewer than 20)
Like count, comment count, preview commentsCounter cache (Redis), see Likes & countersMulti-get; approximate counts are fine
"Has the viewer liked / saved this?"Like store keyed by (post_id, user_id) or a per-viewer recent-likes setBatched existence check for (viewer, 20 posts)
Media URLsDerived from media keysGenerate signed CDN URLs for the right renditions (see CDN)
Visibility checksGraph + privacy + block listsDrop deleted, blocked, now-private, or unfollowed items

The rules that make hydration fast:

Hydration happens after ranking for the final page, but ranking also needs some of this data (as features), which is why ranking systems keep their own feature store optimised for bulk reads.

7.2 The read-path sequence

 App        API GW     Feed Svc      Redis        Graph/celeb    Ranking svc    Hydration caches   Ads svc
  | GET /feed  |           |           |            cache        (+features)    (post,user,counts)    |
  |----------->|---------->|           |              |              |                |              |
  |            |           |-- ZREVRANGEBYSCORE timeline:V (500 IDs) ->|            |                |   |
  |            |           |-- followed_celebrities(V) + their recent posts -------->|              |   |
  |            |           |   (both calls in parallel; ~2-5 ms)       |              |                |   |
  |            |           |   merge + drop already-seen IDs           |              |                |   |
  |            |           |-- score(V, ~500 candidates) ------------------------------->|            |   |
  |            |           |-- request ad candidates (in parallel) ---------------------------------------->|
  |            |           |<-- ranked list (scores) ----------------------------------|              |   |
  |            |           |   re-rank: diversity, integrity, freshness                |              |   |
  |            |           |   blend ads into slots; cut first page of ~12             |              |   |
  |            |           |-- multi-get posts, authors, counts, viewer_liked ---------------------->|   |
  |            |           |<-- hydrated objects ---------------------------------------------------|   |
  |            |           |   visibility filter, sign CDN URLs, build next cursor     |              |   |
  |<-- 200 JSON (posts + next_cursor) ----|            |              |              |                |   |
  |  app downloads media from the CDN, logs impressions (seen posts) asynchronously    |              |   |

A realistic server-side latency budget for this path (assumptions): timeline + celebrity fetch 5 ms, ranking 50–100 ms (the biggest chunk), hydration 10–20 ms, serialisation 5 ms — about 100–150 ms total at p50 and under ~300 ms at p99.

8. Ranking: from chronological to ML-ranked

For its first six years Instagram's feed was reverse-chronological: newest posts first. In 2016 Instagram announced it would move to a ranked feed, because users were missing a large share of posts from people they cared about — with hundreds of posts arriving between visits, "newest first" means "whatever happened to be posted in the last hour first". (Later, Instagram also added "You're all caught up" markers and, in 2022, optional "Following" and "Favorites" views that are chronological again — a good reminder that ranking is a product choice, not just a technical one.)

① Candidate generation — ~1,000–2,000 unseen posts (timeline + celebrities + suggestions) ② Lightweight first-pass model — prune to ~500 using cheap features ③ Heavy neural net: P(like), P(comment), P(save)… → score ④ Re-rank: diversity, integrity, freshness ⑤ Blend ads → page of ~10–20
The ranking funnel: each stage is more expensive per item, so each stage sees fewer items.

8.1 Stage ① — candidate generation

Collect everything that could be shown: the pushed timeline, pulled celebrity posts, and (in modern Instagram) recommended posts from accounts you don't follow, produced by separate retrieval systems similar to Explore's. Already-seen posts are removed here. The output is a few hundred to a few thousand post IDs with minimal metadata.

8.2 Stage ② — feature lookup

A feature is a number the model can use. Instagram has publicly described the main signal families it uses (paraphrased):

📝 About the post

How popular it is and how fast it is gaining likes, its age, type (photo / carousel / video), length, location, topic.

👤 About the author

How often people interact with them, how recently they posted, whether they're a close friend or a business.

📱 About your activity

What kinds of posts you tend to like, save or watch to the end; how long your sessions are; device and network.

🤝 Your history with the author

Do you comment on each other's posts, DM each other, visit their profile, tag each other? This "affinity" is one of the strongest signals.

Features come from a low-latency feature store (a key-value store of precomputed aggregates, refreshed by batch and streaming jobs that consume the Kafka event stream) plus real-time counters (likes in the last 10 minutes). The Analytics & ML pipeline computes them and trains the models offline.

8.3 Stage ③ — ML scoring

Modern feed rankers are multi-task models: a neural network predicts several probabilities at once for (viewer, post):

P(like)  P(comment)  P(share)  P(save)  P(dwell > 5 s)  P(profile tap)
P(hide)  P(report)  P(“see fewer”)                          ← negative events

score = w1·P(like) + w2·P(comment) + w3·P(share) + w4·P(save) + w5·P(dwell)
        − w6·P(hide) − w7·P(report)

The weights encode product priorities (e.g. comments and shares indicate more meaningful interaction than a quick like). Because the heavy model costs maybe a millisecond of GPU/CPU time per candidate, it only runs on the few hundred survivors of the first-pass model. Scoring is a batched call to a model-serving fleet; the Feed Service sends the viewer's features once plus a matrix of candidate features.

8.4 Stage ④ — re-ranking for diversity, integrity and freshness

Sorting purely by score produces bad feeds: five posts in a row from the same prolific friend, all videos, or one viral topic everywhere. A rules + lightweight-model pass adjusts the list:

8.5 Stage ⑤ — ad insertion

Ads are not stored in timelines. They're chosen per request by a separate ads system, called in parallel with ranking so it adds no latency:

  1. The ads service retrieves eligible ads for the viewer (targeting, budget, frequency caps).
  2. It runs an auction: roughly bid × predicted action rate + ad quality decides which ads win.
  3. The Feed Service places winning ads into slots with spacing rules — e.g. no ad in the first position, at least several organic posts between ads, ad load adjusted per user. The positions themselves can be optimised by a model that weighs ad revenue against the predicted drop in engagement.
  4. Impressions and clicks are logged as events for billing and model training.

9. Pagination, deduplication and freshness

9.1 Why offset pagination breaks

The naive API is GET /feed?page=2 (offset = 20). But the feed changes between requests: if three new posts arrive while you look at page 1, page 2 starts three items "earlier" and you see three duplicates. Deletions cause the opposite — skipped posts. Offsets are also expensive for databases (they must skip rows).

9.2 Cursor pagination

A cursor is an opaque token that marks where you stopped, not how many you've seen (see API design). For a chronological feed the cursor encodes the last (timestamp, post_id) returned:

GET /v1/feed?limit=12
→ { "items": [...12 posts...], "next_cursor": "eyJ0cyI6MTcyOTk5OTAwMDQ1NiwiaWQiOjMxNz..." }

GET /v1/feed?limit=12&cursor=eyJ0cyI6MTcyOTk5OTAwMDQ1NiwiaWQiOjMxNz...
→ server decodes {ts: 1729999000456, id: 317...} and reads strictly older entries:
  ZREVRANGEBYSCORE timeline:V (1729999000456 -inf LIMIT 0 12

New posts arriving at the head never shift what's "older than the cursor", so there are no duplicates or gaps. Base64-encoding (and ideally signing) the cursor keeps clients from depending on its internals.

9.3 Pagination of a ranked feed

Ranked feeds are trickier: there's no single sort key, and re-running the ranker for page 2 could reorder everything (new likes change scores). The common solution is a feed session:

  1. On the first request (app open or pull-to-refresh), rank the full candidate set once and store the ordered list of ~200–500 IDs in a short-lived cache entry feedsession:{session_id} (TTL ~30 minutes).
  2. The cursor encodes {session_id, position}. Page 2 simply reads positions 12–23 from the stored ranking and hydrates them — no re-ranking, stable order, very cheap.
  3. When the session's list runs out, the server ranks the next batch of older candidates, excluding everything already served.

9.4 Dedup of seen posts

Users should not see the same post again and again across sessions. The app reports impressions (a post was on screen for, say, > 50% of its area for a moment) in batches. The server keeps a per-user seen set: a capped set of recently seen post IDs or a compact Bloom filter (a probabilistic set: no false negatives, a small rate of false positives — occasionally hiding an unseen post, which is acceptable). Candidate generation removes seen IDs; the ranker may also demote rather than remove, because re-showing a friend's post you scrolled past quickly can be fine. Within a single response, a simple in-memory hash set guarantees the same ID is never returned twice (e.g. if a post arrived via both push and pull).

9.5 Freshness and the "New posts" indicator

Freshness has two parts:

# cheap "anything new?" check: count entries newer than the client's head timestamp
ZCOUNT timeline:42 (1730000000123 +inf      → 3   (show "New posts" pill)

A related trick: on app open, the client renders its locally cached last feed immediately (see Clients), then swaps in fresh results — the app feels instant even if the network is slow.

10. Rebuilding a timeline on cache miss

A user's timeline key may be missing because they were inactive (TTL expired, or fan-out skipped them), because they're brand new, or because a Redis node was lost. The Feed Service then falls back to pull for that one user:

def get_timeline(viewer_id):
    ids = redis.zrevrange(f"timeline:{viewer_id}", 0, 499, withscores=True)
    if ids:
        return ids
    # Cache miss → rebuild. Single-flight lock so 5 concurrent requests from the
    # same user (app open + prefetch) don't all rebuild at once.
    with lock(f"rebuild:{viewer_id}", ttl_s=5) as got_lock:
        if not got_lock:
            return wait_for_or_fallback(viewer_id)
        followees = graph.following(viewer_id)                 # e.g. 500 IDs
        recent = post_index.recent_posts_multi(followees, per_author=10, max_age_days=7)
        ids = merge_feeds(recent, limit=500)                   # k-way merge (section 3.1)
        redis.pipeline() \
             .zadd(f"timeline:{viewer_id}", {pid: ts for pid, ts in ids}) \
             .expire(f"timeline:{viewer_id}", 14 * 86400).execute()
        mark_active(viewer_id)        # fan-out workers will push to this user again
        return ids

11. Failure modes and graceful degradation

The feed must almost never show an error screen. Design every dependency as optional where possible (see Resilience patterns):

FailureDegradation strategy
Ranking service slow / downTimeout after ~100 ms → fall back to chronological order (or a cheap heuristic like recency × affinity from a cached table). The feed is a bit worse, not broken. A circuit breaker stops calling the ranker while it recovers.
Feature store partially unavailableScore with default/missing-feature values or with the first-pass model only.
Redis timeline shard downFail over to its replica; if none, rebuild via pull (rate-limited) or serve the celebrity/pull part only.
Fan-out backlog (Kafka lag growing)Posts appear late. Mitigate by autoscaling workers, isolating big fan-outs, and merging each followee's very latest posts at read time for active users.
Hydration dependency fails (counts, viewer-liked)Render posts without that field; drop only posts whose core metadata is missing.
Ads service timeoutServe the page without ads (lost revenue for one page beats a slow feed).
Whole region degradedFail over to another region; the app shows its locally cached feed meanwhile.
Overload (e.g. New Year's Eve)Load shedding: smaller pages, skip heavy models, lower prefetch, reject low-priority background refreshes first (see Rate limiting).
🚨 Don't let retries amplify an outage If the ranker is slow and every Feed Service instance retries 3 times, you've quadrupled load on a struggling service. Use retry budgets, jittered backoff and circuit breakers, and prefer falling back (chronological) over retrying on the latency-critical read path.

12. How Instagram uses fan-out and ranking

Putting it together with the main architecture diagram:

  1. Upload Service writes the post metadata to the Post store (Cassandra, sharded by user_id) and publishes post.created to Kafka.
  2. Feed Fan-out Workers consume it, read the author's followers from the Social Graph store, skip celebrities and inactive followers, and write the post ID into each follower's timeline in the Feed Cache (Redis).
  3. The News Feed Service reads the viewer's timeline, pulls in followed celebrities' recent posts, removes seen items, calls the ranking models (trained by the Analytics & ML pipeline), blends ads, hydrates the top items and returns JSON with signed CDN URLs.
  4. The app downloads media from the CDN and logs impressions, which flow back through Kafka into the seen-set and the training data.
📸 In Instagram Instagram's public engineering talks and posts describe a stack that started as a Django monolith on sharded PostgreSQL and moved feed and other high-volume data to Cassandra, with heavy use of Redis/Memcache caching. In 2016 Instagram moved from a chronological to a ranked feed, and has since publicly explained that ranking uses signals about the post, the author, your activity and your interaction history with the author, and that the feed, Stories, Explore and Reels each have their own ranking systems. The exact thresholds, model architectures and cache layouts are internal; the design in this lesson — hybrid push/pull, ID-only timelines, multi-stage ranking, cursor-based feed sessions — is the standard industry pattern used by large social feeds (Twitter publicly described a very similar Redis-timeline hybrid for its home timeline).
📸 Rough numbers for an Instagram-scale feed (assumptions)

13. Trade-offs & common pitfalls

✅ Hybrid push/pull + ranking: benefits

  • Reads are one cache lookup plus a tiny merge — low, predictable latency
  • Celebrity posts cost one write, not 100M
  • Inactive users cost nothing
  • ID-only timelines: edits and counts are always fresh after hydration
  • Ranking surfaces the posts users care about most
  • Every stage has a fallback, so the feed degrades instead of failing

⚠️ Costs

  • Two code paths (push and pull) to build, test and keep consistent
  • Terabytes of RAM for timelines
  • Fan-out lag: eventual, not immediate, consistency
  • Stale entries after unfollow/delete need read-time filtering
  • Ranking adds 50–100 ms and a large ML infrastructure
  • Ranked feeds are harder to paginate and to explain to users

Common pitfalls

14. Real-world technologies

TechnologyRole in a feed systemWhen to use it
Redis (Cluster)Timeline sorted sets, seen-sets, feed sessions, countersLow-latency in-memory structures with rich commands; the default choice for timelines
MemcachedLook-aside cache for post/user objects during hydrationSimple, very fast key-value caching of hydrated objects at huge scale
Cassandra / ScyllaDB / HBaseDurable post store, per-author post lists, persisted timelinesWrite-heavy, partitioned time-series data (author_id → posts by time)
Kafkapost.created / follow / impression events driving fan-out and featuresDurable, replayable event log with consumer groups
Flink / Spark StreamingReal-time features (engagement velocity), impression processingStream aggregations feeding the feature store
Feature stores (Feast, internal systems)Low-latency lookup of precomputed features at ranking timeAny production ML ranking system
PyTorch + inference servers (TorchServe, Triton)Training and serving multi-task ranking modelsThe heavy scoring stage
Stream/GetStream, Amazon PersonalizeManaged feed and recommendation productsSmaller companies that don't want to build fan-out and ranking in-house

15. Interview corner

Design the Instagram home feed. Push or pull?

Neither alone — a hybrid. The feed is ~100:1 read-heavy, so I precompute: on post.created (via Kafka), fan-out workers push the post ID into each active follower's Redis sorted-set timeline (capped ~500, TTL ~2 weeks). Accounts above a follower threshold (~1M) are not fanned out; their recent posts sit in a hot per-author cache and are merged in at read time. Read = one ZREVRANGE + a small k-way merge with ~10 celebrity lists, then rank, hydrate with batched multi-gets, and return a cursor. Inactive users are skipped and their timeline is rebuilt via pull when they return.

A celebrity with 300M followers posts. Walk me through what happens.

The Upload Service stores the post once and emits post.created. The fan-out worker sees the author is above the threshold and does not fan out (otherwise: 300M inserts, ~19 GB of RAM, minutes of lag blocking other posts). The post is added to the author's recent-posts list, which is replicated across many cache nodes because it's a hot read key. When any follower opens the feed, the Feed Service fetches the list of celebrities they follow, multi-gets their recent posts (cache hits) and merges them with the precomputed timeline. Notifications for the post go through a separate, throttled pipeline (see Notifications), and the CDN may be pre-warmed for the media.

Why store only post IDs in the timeline instead of whole posts?

Memory (8–16 bytes vs several KB per entry — 100×+ difference across 500M timelines), and correctness: captions get edited, counts change every second, posts get deleted. With IDs, each post's data lives in one place and is hydrated fresh at read time from a shared cache with a high hit ratio. Updating a full copy in millions of timelines would turn every like into a fan-out.

How do you paginate a ranked feed without duplicates?

Use a feed session: on the first request, rank the candidate set once and store the ordered ID list in a short-lived cache (TTL ~30 min); return a cursor of {session_id, offset}. Subsequent pages read the stored order — stable and cheap. When it runs out, rank the next batch of older candidates excluding everything served. Across sessions, a per-user seen-set (capped set or Bloom filter fed by client impressions) removes posts already viewed, and a per-response hash set dedups items that arrive via both push and pull.

The ranking service is down. What does the user see?

A feed, not an error. The Feed Service calls the ranker with a tight timeout behind a circuit breaker; on failure it orders candidates chronologically (or by a cheap cached heuristic such as recency × author affinity), still filters seen and invisible posts, and hydrates normally. We'd emit metrics on fallback rate and page on it, but availability of the feed is preserved — a deliberate availability-over-quality trade-off.

How do you handle unfollow, block and post deletion with precomputed timelines?

Two layers. Best effort: async jobs remove the unfollowed author's IDs from the viewer's timeline, or ZREM a deleted post from followers' timelines. Guarantee: every read re-checks visibility during hydration — post deleted? author blocked or no longer followed? account now private? — and drops the item. The timeline is just a cache of candidates; it is never the source of truth for permissions.

Estimate the memory needed for timelines.

Assume 500M active users × 500 entries. In a Redis sorted set each entry is ~64 bytes → 32 KB/user → ~16 TB, so ~250 nodes of 64 GB plus replicas. A packed encoding (8-byte ID + 4-byte timestamp) gives ~6 KB/user → ~3 TB. Reduce further by lowering the cap, shortening the TTL for less active users, or not maintaining timelines for users inactive more than a couple of weeks.

Why did Instagram move to a ranked feed, and what does a ranking pipeline look like?

In 2016, because with many accounts posting between visits, chronological order meant users missed a large share of posts from people they cared about. The pipeline: candidate generation (timeline + celebrity pull + recommendations, minus seen) → feature lookup from a feature store → a cheap first-pass model to prune → a heavy multi-task neural net predicting P(like), P(comment), P(save), P(hide)… combined into a weighted score → re-ranking for author/format diversity, integrity demotions and freshness → ad blending into spaced slots → page cut and hydration.

16. Check your understanding

A user follows 800 accounts. In a pure pull model, roughly how many backend lookups does opening the feed require, and why is latency the main problem?

~800 "recent posts" lookups (perhaps 100–200 RPCs after batching per shard), plus the follow-list lookup. Latency is dominated by the slowest of those parallel calls: with 150 calls each having a 1% chance of being slow, the chance at least one is slow is 1 − 0.99150 ≈ 78%. The merge itself is cheap CPU.

An account with 20M followers posts 4 times a day. How many timeline writes per day does pure push cause, and how much does the hybrid model save?

20M × 4 = 80M writes/day from one account (and ~1.3 GB of redundant RAM per post at 64 B/entry). Above the threshold, the hybrid writes each post once — saving essentially all 80M writes, at the cost of one extra cached-list read per follower feed request.

Why is a Redis sorted set a better timeline structure than a list when fan-out workers process events at-least-once and possibly out of order?

A sorted set orders by score (timestamp), so out-of-order arrivals still end up in the right place, and re-adding the same member just updates its score — duplicate deliveries are idempotent. A list would hold duplicates and be ordered by arrival time.

What goes wrong with GET /feed?page=3, and what replaces it?

Offsets shift when new posts arrive or old ones are deleted, causing duplicates or skipped posts, and they're expensive for storage engines. Replace with an opaque cursor encoding the last (timestamp, id) for chronological feeds, or {feed_session_id, position} for ranked feeds.

Scenario: a Redis node holding 3M timelines dies without a replica during peak. What happens and how should the system protect itself?

Those 3M users miss their timelines and would all trigger pull-based rebuilds — hundreds of lookups each, a thundering herd on the post store. Protections: replicas with automatic failover, single-flight locks per user, a rate-limited rebuild queue, serving a degraded feed (celebrity pull + client-cached feed, chronological) while rebuilding, and rebuilding lazily only for users who actually open the app.

Why are ads inserted at read time instead of being fanned out into timelines like posts?

Ads are chosen per viewer per request by an auction that depends on budgets, pacing, frequency caps and real-time predictions — all of which change minute to minute. Precomputing them would show stale or over-budget ads. Calling the ads system in parallel with ranking adds little latency.

Which hydration fields can be dropped if their service times out, and which cause the post to be dropped?

Optional: like/comment counts, preview comments, viewer-liked flag (render without them). Required: core post metadata (media keys, author) and the visibility check — without them you can't render the post or can't be sure the viewer is allowed to see it, so the post is dropped.

Your timelines cap at 500 entries. A user scrolls past all 500. What now?

Switch to pull for older content: query followees' post lists for posts older than the last cursor and merge (expensive but rare — very few users scroll that far). Many products instead show "You're all caught up" and then suggested posts, which is both cheaper and a better experience.

🎯 Key takeaways

Related lessons