Want it harder? Hide the model answer and work each phase in a scratchpad against its time budget, then reveal and score yourself. Or simply read on: the checkpoints below work either way.
How Do You Show Three Billion People the Few Posts They Care About Most, in the Half-Second After They Open the App?
It is 9 pm in Bengaluru. Meera opens Instagram on the bus home. She follows 600 accounts: friends from college, her cousin who just had a baby, a dozen chefs, two football clubs and a few hundred more. Since she last looked, this afternoon, they have posted 340 photos, videos and reels, and 90 of them have stories up.
She will scroll for maybe two minutes. In that time she'll see perhaps twenty posts. Her cousin's baby photo, posted three hours ago, has to be one of the first. The stories tray at the top has to show the people she actually watches, with the first story already loaded when she taps. And every one of those photos has to arrive sharp, on a bus, on a phone signal that comes and goes.
- 3 billion — monthly active users, announced by Mark Zuckerberg on 24 September 2025
- 70% — of their feed that people missed on average, the reason Instagram began ranking it (Instagram, March 2016)
- 500 million — people using Instagram Stories every day, the last daily figure Meta published (Q4 2018 earnings call, January 2019)
- 4 stages — retrieval, first-stage ranking, second-stage ranking and final reranking in Explore, visited by "hundreds of millions" of people a day (Meta engineering, August 2023)
- 5 — interactions Feed ranking predicts for each post: time spent, commenting, liking, sharing, tapping the profile photo (Adam Mosseri, May 2023)
- 94% — less compute for basic video encodings after repackaging instead of re-encoding (Meta engineering, November 2022)
For context:
In December 2011, with 14 million users and three engineers, Instagram wrote that Redis powered its main feed and that it did "feed fan-out" in a task queue
Meta doesn't publish daily post volumes, so the estimates below state their own assumptions
Everything not in the list above is an illustrative design target, not Instagram's internal architecture
This is the system we'll design today.
Instagram has 3 billion monthly users; before ranking, people missed about 70% of their feeds. On stated assumptions the feed peaks near 700,000 pages a second, ~300 reads per post, and ~5.7 PB of new media a day. Media is stored once and resized, post IDs sort by time and fan out to inboxes (huge accounts are pulled at read time), feeds are ranked in stages on five predicted interactions, and the stories tray reads an index that expires at 24 hours. The deep dives cover media and video encodings, IDs and fan-out, the ranking funnel and stories, then huge accounts, deletes and New Year's Eve.
The Interview Begins#
You're interviewing at a company building a photo-sharing app. The interviewer hands you a phone with the app open. She scrolls: a friend's photo, a reel, a carousel from a restaurant, a suggested post. At the top, a row of circles with coloured rings.
"Two things on this screen are the whole product: that feed and that row of stories. Design what fills them."
People post photos, carousels and videos to their followers, and stories that leave the tray after 24 hours. When anyone opens the app they see the stories of the accounts they follow at the top and a ranked feed of recent posts below, with media that loads quickly on any network.
Requirements
- Upload photos and videos and make them viewable at every size the app shows
- Build each person's feed from the accounts they follow, ranked so the most relevant come first
- Stories: a tray of accounts with unseen stories, ranked, expiring after 24 hours
- Feed and tray on screen in well under a second, media loading as you scroll
- Survive an account with hundreds of millions of followers posting
"Leave out Direct messages, Reels recommendations, search and ads. Feed, stories, media."
Phase 1: Requirements Clarification (5 minutes)#
"The feed is a read problem and the media is a storage problem; I want numbers for both."
Your Questions#
"How many people?"
"Instagram reached 3 billion monthly active users in September 2025. Meta doesn't publish a daily figure for Instagram; assume half of them open it on a given day."
"Chronological, or ranked?"
"Ranked. Instagram started ranking the feed in 2016 because people missed 'on average 70 percent of their feeds'. Adam Mosseri wrote in 2023 that each part of the app, 'Feed, Stories, Explore, Reels, Search and more', uses 'its own algorithm tailored to how people use it'."
"What does Feed ranking look at?"
"By his account, in rough order of importance: your activity, information about the post, information about the person who posted, and your history with them. It predicts five things for each post: how long you'll spend on it, and how likely you are to comment, like, share or tap the profile photo."
"And stories?"
"They stay in the tray for 24 hours, and since 2017 they 'automatically save to your archive when they expire'. The tray is ranked on viewing history, engagement history and closeness. Meta last published a daily Stories figure in January 2019: 500 million people a day."
"Biggest accounts?"
"Hundreds of millions of followers. Assume one with 400 million posts during a final."
Functional Requirements#
- Post: photos, carousels and videos, with caption, location and tags; visible to followers (approved followers for private accounts)
- Media: every photo and video stored once and served at the sizes and encodings each screen needs
- Feed: posts from followed accounts, ranked; suggested posts mixed in once the new ones run out; "seen" posts not shown again at the top
- Stories: post a story to everyone or to a close-friends list; a ranked tray of accounts with unseen stories; viewers list for the author; expiry from the tray after 24 hours; archive
- Interactions: like, comment, share, save; each one feeds ranking
- Delete and privacy: a deleted post or a blocked viewer disappears from feeds within seconds
Non-Functional Requirements#
Latency (design targets)
- Feed first page: p99 < 500 ms server time; stories tray p99 < 300 ms
- First visible photo: < 1 s on a typical mobile connection
- New post visible to followers who open the app: within a few seconds, minutes for the largest accounts
Scale
- 1.5 billion daily users (assumed half of 3 billion monthly)
- An account with 400 million followers posting at a peak moment
Durability
- No posted photo or video ever lost; expired stories leave the tray but stay in the author's archive
Phase 2: Back of the Envelope Estimation (5 minutes)#
"Meta doesn't publish posting volumes, so every rate below is an assumption I'll say out loud. The shape is what matters: reads dwarf writes, and media bytes dwarf everything."
Before the numbers: how many feed requests at the evening peak?
1.5 billion daily users each open the feed 10 times a day, and each open loads 2 pages. The evening peak is 2× the average. How many feed pages a second at the peak? If ranking scores 500 candidates per page, how many model scores a second is that?
One strong answer
- Pages a day: 1.5 billion × 10 × 2 = 30 billion.
- Average: 30 billion ÷ 86,400 s ≈ 350,000 pages a second; at a 2× peak, ~700,000. Design for 800,000.
- Scores: 700,000 × 500 = 350 million model scores a second, which is why a cheap first stage cuts candidates before the expensive model runs.
Traffic Estimation#
| Value | |
|---|---|
| Daily users (half of 3 billion monthly) | 1.5 billion |
| Feed pages a day (10 opens × 2 pages) | 30 billion |
| Feed pages a second, average / peak | ~350,000 / ~700,000 |
| Stories tray loads a second, peak | ~700,000 (one per feed open) |
- Posts: assume 100 million feed posts a day: ~1,160 a second, ~2,300 at peak
- Story frames: assume 1 billion a day: ~11,600 a second, ~23,000 at peak
- Story views: 500 million daily Stories users × 30 frames watched = 15 billion seen-marks a day, ~174,000 writes a second, ~350,000 at peak
Conclusion: the feed is read about 300 times for every post written (30 billion pages, 100 million posts). Do the expensive work once per post, and keep each read cheap.
Fan-Out Estimation#
- Average post: delivered to ~200 followers' inboxes: 100 million × 200 = 20 billion inbox writes a day, ~230,000 a second
- The 400-million-follower account: one post = 400 million inbox writes; at 1 million writes a second that is ~7 minutes, and 10 posts during a final would queue for over an hour
- At read time instead: each of Meera's feed opens fetches recent posts from the few huge accounts she follows and merges them: a few extra lookups per read, served from a cache
Conclusion: push post IDs to normal accounts' followers when they post; pull from huge accounts when the reader opens the feed. The Twitter quest (Bonus Problem 10) works through this hybrid in depth.
Media Estimation#
- Photos: 60 million a day × (a ~3 MB original + ~1 MB of resized copies) = 240 TB a day
- Feed videos: 40 million a day × (~30 MB original + ~30 MB of encodings) = 2.4 PB a day
- Story frames: 700 million photos × 1 MB + 300 million videos × 8 MB = 3.1 PB a day
- Total new media: ~5.7 PB a day; ~2.1 EB a year before replication
- Reads: 700,000 pages a second × ~10 items each = 7 million media fetches a second at peak; at ~150 KB average, ~8 Tbps, almost all of it from CDN caches
Conclusion: media is where the bytes and the money are. Feed data (IDs, scores, captions) is tiny beside it, and expired stories still cost storage because they move to the archive rather than being deleted.
Key Metrics Summary#
| Metric | Value |
|---|---|
| Daily users (assumed) | 1.5 billion |
| Feed pages at peak | ~700,000/s |
| Ranking scores at peak | ~350 million/s |
| Inbox writes | ~20 billion a day |
| Story seen-marks | ~15 billion a day |
| New media | ~5.7 PB a day |
| Media egress at peak | ~7 million fetches/s, ~8 Tbps |
Requirements and estimation
Q1/3 30 billion feed pages a day and 100 million posts a day. Roughly how many times is the feed read per post written?
Answer & explanation
Answer: C
30 billion ÷ 100 million = 300. Work done once per post is cheap; work done per read is multiplied.
Q2/3 An account with 400 million followers posts. Why not write the post ID to every follower's inbox?
Answer & explanation
Answer: B
At a million writes a second it's ~7 minutes per post. Pulling those posts at read time costs a few cached lookups.
Q3/3 Stories leave the tray after 24 hours. Why does their storage not simply disappear?
Answer & explanation
Answer: C
Since 2017 stories automatically save to the archive when they expire, so they move to cheaper storage rather than being deleted.
Phase 3: High-Level Design (10 minutes)#
Sketch it first: Meera's cousin posts a baby photo
Trace the photo from her cousin's phone to Meera's screen three hours later. What happens to the image bytes, and where do they live? How does the post's ID reach Meera? What runs when Meera opens the app, and in what order do the feed and the tray load?
One strong answer
- Bytes first: the app uploads the photo to a media service, which stores the original and makes resized copies; each copy gets a key and is served through the CDN.
- Then the post: a small record (author, media keys, caption, time) is written with a time-sortable ID to the author's shard.
- Then fan-out: a background job adds the post ID to the inboxes of the cousin's followers, Meera included. The upload doesn't wait for it.
- Meera opens the app: the tray service returns accounts with unseen stories, ranked; the feed service reads her inbox (plus recent posts from any huge accounts she follows), drops what she has seen, ranks the rest in stages, and returns the first page of IDs.
- Hydrate and fetch: the page is filled in with captions, counts and media URLs; the app fetches the right-sized image for her screen from the nearest CDN edge, and prefetches the next few.
"Three planes: media (bytes), posts and inboxes (IDs), and ranking (decisions). Each scales in a different way."
System Architecture#
%% title: Feed and stories architecture
flowchart TD
subgraph L1["Layer 1 · Clients"]
direction LR
AND@{ icon: "tech:android", label: "Android app" }
IOS@{ icon: "tech:apple", label: "iOS app" }
WEBC@{ icon: "sd:browser", label: "Web" }
end
subgraph L2["Layer 2 · Edge"]
direction LR
CDN@{ icon: "sd:cdn", label: "CDN<br/>images and video segments" }
API@{ icon: "sd:api", label: "API gateway<br/>feed, tray, post" }
end
subgraph L3["Layer 3 · Media"]
direction LR
MED["Media service<br/>resize, encode, store once"]:::service
BLOB@{ icon: "sd:object-storage", label: "Blob storage<br/>hot, then warm tiers" }
end
subgraph L4["Layer 4 · Posts and inboxes"]
direction LR
POST@{ icon: "tech:postgresql", label: "PostgreSQL · posts<br/>sharded by author" }
FAN@{ icon: "sd:worker", label: "Fan-out workers<br/>post IDs to followers" }
INB@{ icon: "tech:cassandra", label: "Cassandra · inboxes<br/>recent post IDs per viewer" }
end
subgraph L5["Layer 5 · Ranking"]
direction LR
FEED["Feed service<br/>candidates, stages, rules"]:::hot
TRAY["Stories tray<br/>unseen, ranked, 24 h"]:::core
FS@{ icon: "sd:ml", label: "Models and features<br/>embeddings, counters" }
end
AND ==> API
IOS ==> API
WEBC ==> API
AND ==>|"media"| CDN
API -->|"upload"| MED
MED --> BLOB
CDN --> BLOB
API -->|"create post"| POST
POST -.->|"new post"| FAN
FAN ==> INB
API ==>|"open feed"| FEED
API --> TRAY
FEED --> INB
FEED --> FS
TRAY --> FS
class AND,IOS,WEBC client
class CDN,API edge
class BLOB,POST,INB store
class FAN service
class FS serviceKEY INSIGHTS:
- Bytes and IDs travel separately. The media service and CDN move megabytes; the post, inbox and ranking systems move 64-bit IDs and a few fields
- Write once per post, read cheaply per open: resizing, encoding and fan-out happen once; the feed read is an inbox lookup, a ranking pass and a cache-friendly hydrate
- Ranking is its own tier with its own models and features, because it is where most of the compute per read goes
Data Flow: One Photo, From Posting to the Top of Meera's Feed#
%% title: A baby photo, from the cousin's phone to Meera's feed sequenceDiagram autonumber participant C as Cousin's app participant M as Media service participant P as Posts shard participant F as Fan-out workers participant I as Meera's inbox participant R as Feed service participant Me as Meera's app C->>M: Upload photo, 3.2 MB M->>M: Store original, make 1080, 640, 320, thumbnail M-->>C: Media keys C->>P: Create post with media keys P-->>C: Post ID, time-sortable P-)F: New post by an account with 420 followers F->>I: Append post ID to 420 inboxes Note over Me: three hours later Me->>R: Open feed R->>I: Recent post IDs, newest first R->>R: Drop seen, rank in stages R-->>Me: Page 1 · the baby photo is first Me->>Me: Fetch the 1080 copy from the CDN
MEANWHILE, IN PARALLEL:
- The stories tray loads in its own request; the first two stories' media is prefetched before Meera taps
- Every like, comment and second of viewing time goes back as events that update the counters and features ranking reads
High-level design
Q1/3 The cousin taps "Share". What must finish before the app shows the post as posted?
Answer & explanation
Answer: C
Fan-out runs in the background, as Instagram wrote in 2011, so posting is as fast for a big account as a small one.
Q2/3 Why do post IDs carry a timestamp in their top bits?
Answer & explanation
Answer: A
Instagram's 2012 design: 41 bits of time first, so an inbox of IDs is already in time order.
Q3/3 Where is most of a feed read's compute spent?
Answer & explanation
Answer: D
Hundreds of candidates × several model predictions each, per page, is the expensive part of a read.
Phase 4: Deep Dives (20 minutes)#
Deep Dive 1: Media, Stored Once and Served at Every Size (Week 4 Concepts: Multi-Tier Caching; Week 1: Replication)#
"Most of the bytes we store are photos and videos, and most of the bytes we send are the same few thousand popular ones. Store each file once, derive the sizes, and let caches do the rest."
This builds on Week 4, Day 5 · Multi-Tier Caching.
One photo, six screens
The cousin's 3.2 MB photo will be shown full-width on a large phone, half-width in a grid, as a thumbnail in notifications, and on the web. What do you store, what do you make at upload versus on demand, and what does Meera's phone actually download?
One strong answer
- Keep the original, never shown directly, so the sizes can be remade later (a new format, a new screen size).
- Make the common sizes at upload: e.g. 1080, 640 and 320 pixels wide plus a small thumbnail, in an efficient format. Each is a few hundred KB at most; the set is ~1 MB.
- Rare sizes on demand, at the edge, then cached.
- Keys are content addresses:
media/<hash of the bytes>/<size>.<format>. The same file shared twice is stored once, and a cached copy never needs invalidating because a changed image gets a new key. - Meera's phone asks for the size that fits its screen, from the nearest CDN edge. A popular post is a cache hit; her cousin's photo, seen by 420 followers, may be one of the few misses that reach storage.
Videos cost far more than photos. Meta's 2022 post on Instagram video explains why: each upload gets "minimum functionality" encodings that every client can play (basic adaptive-bitrate ladders, plus progressive files for old app versions) and "advanced encodings" with newer compression. Instagram was spending "more than 80 percent" of its video compute on minimum-functionality encodings while advanced ones "covered only 15 percent of total watch time". Its fix was to stop encoding the basic ABR ladder separately: it repackaged the progressive encodings' frames into an ABR-capable structure, which took 0.36 seconds instead of the 86.17 CPU-seconds a 720p encode needed. That cut the cost of basic ABR encodings by 94% and let advanced-encoding watch time coverage grow by 33%.
%% title: One upload through the media pipeline
flowchart TD
UP(["Upload<br/>photo or video"]):::client
ORIG@{ icon: "sd:object-storage", label: "Original<br/>kept, never served" }
subgraph PH["Photos · at upload"]
direction LR
SIZES["1080, 640, 320,<br/>thumbnail"]:::service
end
subgraph VI["Videos · by watch time"]
direction LR
BASIC["Minimum functionality<br/>encode once, repackage"]:::core
ADV["Advanced encodings<br/>for videos people watch"]:::queue
end
HOT@{ icon: "sd:object-storage", label: "Hot tier<br/>new and popular" }
WARM@{ icon: "sd:object-storage", label: "Warm tier<br/>older, erasure-coded" }
EDGE@{ icon: "sd:cdn", label: "CDN edges<br/>most reads end here" }
UP --> ORIG
ORIG --> SIZES --> HOT
ORIG --> BASIC --> HOT
ORIG -.->|"once it is watched enough"| ADV -.-> HOT
HOT -.->|"after weeks"| WARM
HOT --> EDGE
WARM --> EDGE
class ORIG,HOT,WARM store
class EDGE edge- Hot, then warm. A post gets most of its views in its first days. Facebook's f4 paper (2014) describes moving such "warm" blobs from triple replication to erasure-coded storage, cutting the effective replication factor from 3.6 to 2.8 or 2.1. The same idea applies here: after a few weeks, a photo's bytes move to cheaper storage; its key doesn't change.
- Expired stories go warm immediately, since only their author's archive will open them.
- Deletes are tombstones first: the post is hidden at once; the media keys are deleted from storage and purged from the CDN afterwards.
# media/keys.py
"""
Content-addressed media keys (illustrative). The same bytes always get the
same key, so a re-share is stored once and a cached copy never goes stale:
an edited image is new bytes, so a new key.
"""
import hashlib
PHOTO_SIZES = (1080, 640, 320, 150)
def media_key(original: bytes) -> str:
return hashlib.sha256(original).hexdigest()[:32]
def derived_url(key: str, width: int, fmt: str = "webp") -> str:
return f"https://cdn.example.com/m/{key}/{width}.{fmt}"
def pick_width(screen_px: int, fraction_of_screen: float) -> int:
"""The smallest stored size that still looks sharp in its slot."""
need = screen_px * fraction_of_screen
fits = [w for w in sorted(PHOTO_SIZES) if w >= need]
return fits[0] if fits else max(PHOTO_SIZES)Deep Dive 2: Posts, IDs and Inboxes (Week 1 Concepts: Partitioning, Hot Keys; Week 4: Feed Caching)#
"The feed starts as a list of candidate post IDs per viewer. Getting them there cheaply is the fan-out problem; making them sortable without a lookup is the ID problem."
This builds on Week 4, Day 4 · Feed Caching and Week 1, Day 1 · Partitioning Deep-Dive.
IDs that sort themselves
Posts are written to thousands of database shards at the same moment. You want 64-bit IDs that are unique across all of them, sortable by time, and that tell you which shard holds the post, without a central ID service. How would you lay out the 64 bits?
One strong answer
- Instagram's 2012 answer: 41 bits of milliseconds since a custom epoch, 13 bits of logical shard ID, 10 bits of a per-shard sequence modulo 1,024.
- Time first: sorting IDs sorts posts by creation time, so an inbox of IDs is already in feed order with no lookups.
- Shard inside: 13 bits address 8,192 logical shards; the logical shard maps to a physical database in configuration, so shards can move between machines "without having to re-bucket any of our data".
- Sequence: 1,024 IDs per shard per millisecond, generated in the database itself with no new service to run.
- Runway: 41 bits of milliseconds is about 69 years from the epoch (Instagram's post says 41 years).
# posts/ids.py
"""
Time-sortable 64-bit IDs in the layout Instagram published in 2012
(illustrative Python; Instagram generated them in PL/pgSQL inside each
logical shard): 41 bits of milliseconds since an epoch, 13 bits of logical
shard, 10 bits of a per-shard sequence.
"""
import time
EPOCH_MS = 1314220021721 # the epoch in Instagram's published example (August 2011)
SHARD_BITS, SEQ_BITS = 13, 10
def make_id(shard_id: int, seq: int, now_ms: int = None) -> int:
now_ms = int(time.time() * 1000) if now_ms is None else now_ms
return ((now_ms - EPOCH_MS) << (SHARD_BITS + SEQ_BITS)) | (shard_id << SEQ_BITS) | (seq % 1024)
def shard_of(post_id: int) -> int:
return (post_id >> SEQ_BITS) & ((1 << SHARD_BITS) - 1)
def time_of_ms(post_id: int) -> int:
return (post_id >> (SHARD_BITS + SEQ_BITS)) + EPOCH_MS
def logical_shard(user_id: int, logical_shards: int = 2000) -> int:
return user_id % logical_shards # the post's example: user 31341 -> shard 1341Fan-out: push for most, pull for the few#
%% title: Hybrid fan-out for a new post
flowchart TD
NEW(["New post<br/>post ID, author"]):::client
CHK{"Author's followers<br/>above 1 million?"}:::core
PUSH@{ icon: "sd:worker", label: "Fan-out workers<br/>batches of 1,000 inboxes" }
INBOX@{ icon: "tech:cassandra", label: "Cassandra · inboxes<br/>newest 1,500 IDs per viewer" }
CEL@{ icon: "tech:redis", label: "Redis · recent posts<br/>of huge accounts" }
READ["Feed read<br/>inbox ∪ huge accounts followed"]:::hot
NEW --> CHK
CHK -->|"no"| PUSH --> INBOX
CHK -->|"yes: skip fan-out"| CEL
INBOX --> READ
CEL --> READ
class INBOX store
class CEL cache
class PUSH service- The inbox is bounded: the newest ~1,500 post IDs per viewer, trimmed on write. Nobody scrolls further back than that through new posts; older ones come from elsewhere if needed.
- Fan-out jobs are idempotent: appending the same post ID to an inbox twice is a no-op, so a retried batch is harmless.
- Inactive followers are skipped: fan-out writes only to viewers active in the last month; a returning viewer's inbox is rebuilt by pulling from the accounts they follow.
- Unfollow and block are filters at read time first (drop IDs from that author), and inbox cleanup later.
# feed/fanout.py
"""
Hybrid fan-out (illustrative). Normal accounts push the new post's ID into
followers' inboxes in batches; huge accounts skip fan-out and are pulled at
read time. Every write is idempotent.
"""
HUGE = 1_000_000
BATCH = 1_000
INBOX_MAX = 1_500
def on_new_post(post: dict, graph, inboxes, celeb_cache, active) -> None:
if graph.follower_count(post["author"]) >= HUGE:
celeb_cache.add_recent(post["author"], post["id"]) # readers pull it
return
for batch in graph.followers(post["author"], batch=BATCH):
viewers = [v for v in batch if active.recently(v)]
inboxes.add_many(viewers, post["id"], keep=INBOX_MAX) # set semantics: retry-safe
def candidates(viewer: int, graph, inboxes, celeb_cache, limit: int = 1_500) -> list:
ids = set(inboxes.recent(viewer, limit))
for author in graph.huge_accounts_followed(viewer): # usually a handful
ids.update(celeb_cache.recent(author, limit=50))
return sorted(ids, reverse=True) # IDs sort by timeMedia and inboxes
Q1/4 Why name a stored photo by a hash of its bytes?
Answer & explanation
Answer: B
A changed image is new bytes and so a new key; nothing cached ever needs invalidating.
Q2/4 In Instagram's 2012 ID layout, what do the 13 middle bits hold?
Answer & explanation
Answer: C
41 bits of time, then 13 of logical shard, then 10 of sequence: the ID says where the row is.
Q3/4 Instagram's basic video encodings once took over 80% of its video compute. What cut their cost by 94%?
Answer & explanation
Answer: C
Repackaging took 0.36 s where a 720p encode took 86 CPU-seconds, freeing compute for advanced encodings.
Q4/4 A post's author has 3 million followers. In the hybrid design, how does the post reach their followers' feeds?
Answer & explanation
Answer: B
Above the threshold, fan-out is skipped; a few cached lookups at read time replace millions of writes.
Deep Dive 3: Ranking the Feed in Stages (Week 7 Concepts: Relevance; Week 8: Event Ingestion)#
"Once we have 1,500 candidate IDs, ranking decides which twenty Meera sees. It is the most expensive part of a read, so it runs as a funnel."
This builds on Week 7, Day 3 · Query Processing and Relevance.
1,500 candidates, 300 ms
Meera has 1,500 candidate posts. Your best model scores one post in 0.5 ms of accelerator time and predicts the five things Mosseri listed; a light model scores one in 0.01 ms. You have about 300 ms. How do you split the work, how do you turn five predictions into one order, and what rules run after the model?
One strong answer
- Filter first, free: drop posts she has seen, posts from blocked or muted accounts, deleted posts. Say 1,500 become 1,200.
- First stage: the light model scores all 1,200: 12 ms. Keep the best 300.
- Second stage: the heavy model scores 300 × 0.5 ms = 150 ms of accelerator time, batched in parallel to ~40 ms of wall time. It predicts time spent and the probabilities of comment, like, share and profile tap.
- One number: a weighted sum of the predictions, the "value" of showing that post, with weights chosen by experiments.
- Final rules: no more than two posts in a row from one account, alternate photos and videos, keep integrity filters, and lift very fresh posts from close friends. Her cousin's baby photo, with a high predicted comment probability and a close relationship, comes first.
What Instagram and Meta have published: Instagram began ranking the feed in March 2016, ordering it by "the likelihood you'll be interested in the content, your relationship with the person posting and the timeliness of the post". In 2023 Mosseri described the signals, in rough order of importance (your activity, information about the post, information about the person who posted, your history of interacting with them), and the five predictions: time spent, comment, like, share and profile tap. Meta's 2023 post on Explore describes the funnel shape: retrieval narrows billions of items, a lightweight first stage scores thousands, a heavy multi-task second stage ranks about a hundred, and a final reranking applies integrity filters and diversity rules. It uses "Two Towers" models whose user embeddings can be cached and whose item embeddings sit in approximate-nearest-neighbour indexes.
%% title: The feed ranking funnel for one page
flowchart TD
C(["1,500 candidates<br/>inbox ∪ huge accounts"]):::client
F1["Filters<br/>seen, blocked, muted, deleted"]:::service
S1@{ icon: "sd:ml", label: "First stage · light model<br/>1,200 → 300" }
S2@{ icon: "sd:ml", label: "Second stage · multi-task<br/>time, comment, like, share, profile tap" }
V["Value = weighted sum<br/>of the five predictions"]:::core
RR["Final rerank<br/>diversity, integrity, freshness"]:::core
OUT(["Page of 10<br/>logged as impressions"]):::ok
FE@{ icon: "tech:redis", label: "Redis · features<br/>counts, closeness, recent activity" }
C --> F1 --> S1 --> S2 --> V --> RR --> OUT
FE -.-> S1
FE -.-> S2
class S1,S2 service
class FE cache# feed/rank.py
"""
Staged feed ranking (illustrative). Cheap filters, a light model over all
candidates, a heavy multi-task model over the survivors, one value per post
from the five predictions, then rules. Weights come from experiments.
"""
from typing import Dict, List
WEIGHTS = {"time_spent_s": 0.02, "p_comment": 4.0, "p_like": 1.0, "p_share": 3.0, "p_profile_tap": 1.5}
FIRST_KEEP = 300
PAGE = 10
MAX_RUN_PER_AUTHOR = 2
def value(pred: Dict[str, float]) -> float:
return sum(WEIGHTS[k] * pred[k] for k in WEIGHTS)
def rank_page(viewer: dict, candidate_ids: List[int], deps) -> List[int]:
posts = deps.posts.get_many(candidate_ids) # cached hydrate
posts = [p for p in posts
if p["id"] not in viewer["seen"]
and p["author"] not in viewer["blocked_or_muted"]
and not p["deleted"]]
light = deps.light_model.score(viewer, posts) # ~0.01 ms each
survivors = [p for _, p in sorted(zip(light, posts), key=lambda x: x[0], reverse=True)][:FIRST_KEEP]
preds = deps.heavy_model.predict(viewer, survivors) # batched multi-task
scored = sorted(((value(pr), p) for pr, p in zip(preds, survivors)), key=lambda x: x[0], reverse=True)
page, run_author, run_len = [], None, 0
for _, p in scored:
if deps.integrity.blocked(p):
continue
if p["author"] == run_author and run_len >= MAX_RUN_PER_AUTHOR:
continue # diversity: no long runs
run_len = run_len + 1 if p["author"] == run_author else 1
run_author = p["author"]
page.append(p["id"])
if len(page) == PAGE:
break
deps.impressions.log(viewer["id"], page) # labels come back as events
return pageFeatures, freshness and the seen state#
- Features are mostly counters: how often Meera comments on her cousin's posts, how many likes a post has gathered in its first hour, how long she watches videos from this account. They are updated from interaction events (Week 8's event ingestion) and read from a cache at ranking time.
- The seen state is a per-viewer set of post IDs that reached the screen, with a short expiry. Without it, the same top post greets her on every open.
- Freshness is a feature, not a sort: a post's age is an input to the model, so a three-hour-old baby photo can outrank a ten-minute-old ad-like post from a brand.
- Fallbacks: if the heavy model is slow or down, serve the first-stage order; if features are missing, serve newest-first from close accounts. A worse feed beats an empty one.
Deep Dive 4: Stories, the Tray and 24 Hours (Week 9 Concepts: Right to Deletion; Week 4: Caching)#
"Stories look like a feed with a timer, but the read is different: the tray asks 'which of the accounts I follow have something I haven't seen?', and the answer changes every few seconds."
The tray in 300 ms
Meera follows 600 accounts; 90 of them have stories up from the last 24 hours, and she has watched all of 30. When she opens the app, how do you find the 60 with unseen stories, order them, and have the first story ready when she taps? What happens at hour 24?
One strong answer
- Active-story index: for each account, the IDs of its stories from the last 24 hours, kept in a cache with a 24-hour expiry per entry. The tray service intersects Meera's followings (600) with accounts that have an entry: 90.
- Seen state: for each viewer, the story IDs she has watched, with a ~48-hour expiry. An account is unseen if its newest story isn't in her set: 60.
- Order: rank the 60 by predicted interest (Mosseri lists viewing history, engagement history and closeness, and predictions such as tapping in, replying or moving on), then put fully-seen accounts at the end.
- Ready on tap: prefetch the first frame of the top two or three accounts while the tray renders.
- At hour 24: the index entry expires, so the story leaves every tray at once without anyone deleting anything from feeds. Its media moves to the author's archive in warm storage, and its viewers list stays available to the author.
%% title: Building one stories tray sequenceDiagram autonumber participant Me as Meera's app participant T as Tray service participant G as Follow graph participant A as Active-story index participant S as Seen state Me->>T: Open app T->>G: Accounts Meera follows · 600 T->>A: Which have stories in the last 24 h? · 90 T->>S: Which of those has Meera seen? · 30 fully T->>T: Rank the 60 unseen, seen ones last T-->>Me: Tray of 90, first 60 ringed Me->>Me: Prefetch first frames of the top 3 Me->>S: Seen story 8812 (on view)
- Audience is part of the index: a close-friends story is indexed with its audience list, so the tray only shows it to people on the list. The check runs at read time, so removing someone from the list hides future reads at once.
- Seen-marks are the write load: ~350,000 a second at peak. They go to a key-value store as small set additions, batched on the phone and sent every few seconds; losing one means a story looks unseen once more, not a correctness failure.
- The viewers list is the same events seen from the author's side: append
(story, viewer, time); count and page through it on demand.
# stories/tray.py
"""
The stories tray (illustrative). An active-story index with a 24-hour expiry
per story, a per-viewer seen set with a 48-hour expiry, and a ranked tray
with unseen accounts first. Nothing is deleted at hour 24: entries expire.
"""
import time
STORY_TTL_S = 24 * 3600
SEEN_TTL_S = 48 * 3600
def post_story(author: int, story_id: int, audience, index, archive) -> None:
index.add(author, story_id, audience=audience, expires_at=time.time() + STORY_TTL_S)
archive.add(author, story_id) # the author's archive keeps it after expiry
def tray(viewer: int, graph, index, seen, ranker) -> list:
now = time.time()
active = index.active_for(graph.following(viewer), now) # {author: [story ids]}
visible = {a: [s for s in ids if index.audience_allows(s, viewer)] for a, ids in active.items()}
visible = {a: ids for a, ids in visible.items() if ids}
seen_ids = seen.get(viewer)
unseen = [a for a, ids in visible.items() if ids[-1] not in seen_ids]
watched = [a for a in visible if a not in unseen]
return ranker.order(viewer, unseen) + ranker.order(viewer, watched)
def mark_seen(viewer: int, story_ids: list, seen) -> None:
seen.add_many(viewer, story_ids, ttl=SEEN_TTL_S) # idempotent set adds, batched by the appRanking and stories
Q1/4 Why rank the feed with a light model first and a heavy model second?
Answer & explanation
Answer: B
Hundreds of heavy predictions per page fit the budget; a thousand-plus would not. The funnel spends compute where it changes the order.
Q2/4 Feed ranking predicts five interactions per post. How do they become one order?
Answer & explanation
Answer: C
One value per post makes the predictions comparable; experiments decide how much a comment is worth against a like.
Q3/4 What makes a story leave every tray at hour 24?
Answer & explanation
Answer: B
The tray is built from the index on every open, so an expiring entry removes the story everywhere at once.
Q4/4 A seen-mark write is lost. What is the consequence?
Answer & explanation
Answer: A
Seen state is a convenience that can be slightly wrong; it is written cheaply, in batches, with an expiry.
Phase 5: Scaling and Edge Cases (5 minutes)#
"Walk me through one post's life, and then the cases that bite."
A Post's First Day#
- 6:00 PM · Posted
- Original and four sizes stored; post written to the cousin's shard; fan-out to 420 inboxes finishes in under a second
- 6:00 PM · Story too
- The same photo as a story: an index entry with a 24-hour expiry; it rings the cousin's circle in 420 trays
- 9:00 PM · Meera opens the app
- The post is in her inbox; ranking puts it first; the 1,080-pixel copy comes from the nearest CDN edge
- Next day, 6:00 PM · Story expires
- The index entry lapses; the story leaves every tray and stays in the cousin's archive
- Three weeks later · Warm storage
- The photo's bytes move to erasure-coded storage; the key and URL don't change
Edge Cases#
Edge case 1: The 400-million-follower account posts during a final
Problem: The account posts a goal celebration and 50 million followers open the app in ten minutes.
Solution: No fan-out: its post is in the huge-accounts cache, merged into each reader's candidates. The media is one key, so the CDN serves it from cache after the first few requests per edge; pre-warm the edges as soon as the post is created.
Edge case 2: Delete a post that's already in millions of inboxes
Problem: The author deletes a post seconds after it fanned out.
Solution: Mark it deleted on its shard; every feed read hydrates posts and drops deleted ones, so it vanishes at once. Inbox entries are cleaned up lazily; media is purged from storage and the CDN afterwards.
Edge case 3: A new user follows nobody
Problem: Their inbox is empty.
Solution: Fill the feed with suggested posts from retrieval over popular and nearby content (the Explore-style funnel), and suggest accounts to follow; switch to the inbox as follows accumulate.
Edge case 4: A private account removes a follower
Problem: A removed follower must stop seeing posts and stories immediately.
Solution: The follow edge is deleted, and every read checks the viewer against the author's audience at hydrate time, so nothing already in an inbox or the story index leaks.
Scaling and edge cases
Q1/3 A post is deleted seconds after fanning out to a million inboxes. How does it disappear from feeds at once?
Answer & explanation
Answer: B
Read-time filtering makes deletes instant; inbox and media cleanup can follow lazily.
Q2/3 Why pre-warm CDN edges when a huge account creates a post?
Answer & explanation
Answer: C
Warming turns the first wave of requests into cache hits instead of a stampede on storage (Week 4's thundering herd).
Q3/3 A private account removes a follower. What stops that follower seeing posts already in their inbox?
Answer & explanation
Answer: D
Permission is checked on every read, so a stale inbox can never show a post to someone no longer allowed.
Phase 6: Monitoring and Operations#
"The dashboard asks: are feeds fast and full, is ranking healthy, are uploads getting in, and is media arriving quickly?"
Monitoring Dashboard#
Feed
| Metric | Now |
|---|---|
| Feed pages | 684,000/s |
| Feed latency p50 / p99 | 142 / 431 ms |
| Pages served from first-stage fallback | 0.3% |
| Pages with fewer than 10 posts | 0.4% |
Ranking
- Heavy model p99: 58 ms per batch; feature cache hit rate 99.2%
- Fan-out queue: 1.4 s oldest job; huge-accounts cache: 2,900 accounts
Stories
- Tray latency p99: 246 ms; seen-marks 331,000/s
- Story index entries expiring per second: 11,800
Media
- Uploads completed: 2,140 posts/s, 21,900 story frames/s; upload failure rate 0.4%
- CDN hit rate 96.8%; first image p75 on mobile 610 ms
SLOs for the Platform#
SLO 1: Fast, full feeds
- Target: first page p99 < 500 ms; 99.5% of pages with a full set of posts
SLO 2: Posts reach followers
- Target: 99% of posts from normal accounts in followers' inboxes within 10 s
SLO 3: Stories that behave
- Target: tray p99 < 300 ms; no story visible in a tray after its 24 hours
SLO 4: Media never lost, quick to load
- Target: 0 posted originals lost; first image under 1 s at p75 on mobile
Reading the dashboard
Q1/3 0.3% of pages are served from the first-stage fallback. When should that worry you?
Answer & explanation
Answer: C
The fallback keeps feeds full when the heavy stage is unhealthy; a rising share is an early alarm on ranking.
Q2/3 The fan-out queue's oldest job grows from 1.4 s to 9 minutes. What do users notice?
Answer & explanation
Answer: A
Reads still work from existing inboxes; only delivery of new posts is delayed.
Q3/3 The CDN hit rate drops from 96.8% to 80%. What happens to storage?
Answer & explanation
Answer: C
Misses go from 3.2% to 20% of 7 million fetches a second: about six times more load on storage.
Final Challenge: Midnight on New Year's Eve#
The interviewer glances at the calendar. "One more."
"Midnight on New Year's Eve in India. In the ten minutes after twelve, story posting jumps to ten times normal in one time zone, everyone opens the app to watch their friends' fireworks, and a cricketer with 270 million followers (assume that) posts a story too. Keep it working and tell me what you'd degrade."
- Normal story frames in India's evening: 4,000 a second (assumption); at midnight 10×
- Story frames: 70% photos at 1 MB, 30% videos at 8 MB
- Tray opens: 400,000 a second for ten minutes (assumption)
- Each tray open triggers 3 prefetched first frames (~200 KB each)
- Seen-marks: 8 per tray open in that window
What do you protect, and what do you degrade?
Work out the upload rate and bytes, the tray load, the seen-mark writes and the prefetch egress. Then decide what to slow down.
One strong answer
- Uploads: 4,000 × 10 = 40,000 story frames a second; bytes: 40,000 × (0.7 × 1 MB + 0.3 × 8 MB) = 40,000 × 3.1 MB = 124 GB/s (~1 Tbps) of ingest into the media service near India.
- Video encoding: 12,000 videos a second; produce only the minimum-functionality versions now (Meta's repackaging trick keeps that cheap), and queue advanced encodings for after 1 am.
- Tray: 400,000 trays a second, each an index-and-seen-set intersection; serve them from caches with the 24-hour index already in memory.
- Seen-marks: 400,000 × 8 = 3.2 million writes a second, ten times the normal peak. The app already batches them; stretch the batch window from a few seconds to 30 s. A missed mark only makes a story look unseen again.
- Prefetch: 400,000 × 3 × 200 KB = 240 GB/s (~1.9 Tbps) from CDN edges, mostly cache hits since friends watch each other's stories.
- The cricketer's story: one entry in the index with 270 million potential viewers. No fan-out happens for stories, so it's just one hot key on the index and one hot media key on the CDN: replicate the index entry across cache nodes and pre-warm the edges.
- Degrade in this order: advanced video encodings, then the heavy ranking stage for trays (use first-stage order), then the seen-mark batch window, then prefetch depth (3 frames down to 1). Never acknowledged uploads, the 24-hour expiry, or audience checks on close-friends stories.
Final challenge · Quest check
Q1/4 40,000 story frames a second, 70% at 1 MB and 30% at 8 MB. Roughly what ingest bandwidth is that?
Answer & explanation
Answer: B
The average frame is 0.7 × 1 + 0.3 × 8 = 3.1 MB; × 40,000 = 124 GB/s, about a terabit a second.
Q2/4 Seen-marks jump to 3.2 million writes a second. What is the safe way to absorb them?
Answer & explanation
Answer: B
Seen state tolerates a little loss and delay, so batching harder is cheap and safe.
Q3/4 The cricketer with 270 million followers posts a story. What load does that create?
Answer & explanation
Answer: C
Stories aren't fanned out; the tray reads the index. Replicate the hot entry and pre-warm the CDN.
Q4/4 Which should be degraded first at midnight?
Answer & explanation
Answer: A
Advanced encodings improve quality later; privacy, expiry and durability are never shed.
Interview Conclusion#
"Why not just show the feed in time order?"
"Because people don't see most of it. Instagram said in 2016 that people missed about 70% of their feeds. Ranking spends a few hundred milliseconds of compute to put the posts someone cares about inside the twenty they'll actually see."
"Why keep fan-out at all, if ranking reorders everything anyway?"
"Fan-out builds the candidate list once per post instead of once per read. With reads 300 times more frequent than posts, that's the cheap side to do the work, except for the handful of huge accounts, where pulling at read time is cheaper."
"The single most important decision?"
"Separate the bytes from the IDs. Media is stored once, by content, and served from caches; posts, inboxes and stories move small IDs that sort by time. That lets each part scale its own way, and keeps ranking, the expensive part, working on IDs and features rather than megabytes."
Summary: Concepts Applied from 10-Week Course#
- Week 1: Data at scale
- Partitioning — logical shards inside 64-bit IDs; posts sharded by author
- Hot keys — huge accounts pulled at read time; hot story entries replicated
- Week 3: Messaging and async processing
- Fan-out as background jobs, idempotent and batched
- Week 4: Caching
- Feed caching — bounded inboxes; multi-tier caching — CDN, hot and warm storage, content-addressed keys
- Week 7: Search systems
- Retrieval then staged ranking; a value model over several predictions
- Week 8: Analytics pipelines
- Interaction events feeding ranking features and seen state
- Week 9: Multi-tenancy and security
- Audience checks at read time; deletes as tombstones, then purges
Why Instagram Matters#
The feed is a decision, not a query
- A chronological feed is a database read; a ranked one is a prediction for every post, for every person, every time they open the app
Bytes and IDs scale differently
- Petabytes of media a day need caches, tiers and formats; billions of feed reads need small, sortable IDs and cheap merges
Ephemeral is a read-side idea
- Stories disappear from the tray by letting an index entry expire, while the media lives on in an archive: "gone" is about what the reader is shown
"Store each photo once, move only IDs, and spend the compute on choosing which twenty to show."
Self-Assessment Checklist#
After studying this case study, you should be able to:
Media:
- Store media once by content address and serve derived sizes through a CDN
- Explain hot and warm tiers and why expired stories still cost storage
- Describe minimum-functionality versus advanced video encodings and where compute goes
Feed:
- Lay out time-sortable 64-bit IDs with a shard inside them
- Design hybrid fan-out with bounded, idempotent inboxes
- Build a staged ranking funnel and turn several predictions into one value
Stories and operations:
- Build a tray from an expiring index and a seen set, with audience checks
- Estimate a midnight spike and choose what to degrade
Sources#
Instagram and Meta:
- Book
Mark Zuckerberg — Instagram reaches 3 billion monthly active users, 24 September 2025 (as reported by Exchange4Media): https://www.exchange4media.com/digital-news/instagram-crosses-3bn-monthly-users-strengthens-focus-on-reels-and-dms-147825.html
- Blog
Instagram — See posts you care about first in your feed (people miss about 70% of their feeds; interest, relationship, timeliness), March 2016: https://about.instagram.com/blog/announcements/see-posts-you-care-about-first-in-your-feed
- Blog
Adam Mosseri — Instagram Ranking Explained (an algorithm per surface; Feed signals and five predictions; Stories signals), May 2023: https://about.instagram.com/blog/announcements/instagram-ranking-explained
- Blog
Instagram — Introducing Stories Highlights and Stories Archive (stories save to the archive when they expire), December 2017: https://about.instagram.com/blog/announcements/introducing-stories-highlights-and-stories-archive
- Read
TheWrap — Instagram Stories just passed 500 million daily users (Zuckerberg on Facebook's Q4 2018 earnings call), January 2019: https://thewrap.com/instagram-stories-just-passed-500-million-daily-users
Engineering posts:
- Blog
Instagram Engineering — What Powers Instagram: Hundreds of Instances, Dozens of Technologies (PostgreSQL, S3 and CloudFront, Redis for the main feed, fan-out in Gearman, 14 million users), December 2011: https://instagram-engineering.com/what-powers-instagram-hundreds-of-instances-dozens-of-technologies-adf2e22da2ad
- Blog
Instagram Engineering — Sharding & IDs at Instagram (41/13/10-bit IDs, logical shards as PostgreSQL schemas), December 2012: https://instagram-engineering.com/sharding-ids-at-instagram-1cf5a71e5a5c
- Blog
Instagram Engineering — Open-sourcing a 10x reduction in Apache Cassandra tail latency (Cassandra since 2012 for Feed and the Direct inbox), March 2018: https://instagram-engineering.com/open-sourcing-a-10x-reduction-in-apache-cassandra-tail-latency-d64f86b43589
- Book
Ryan Peterman, Haixia Shi — Reducing Instagram's basic video compute time by 94 percent (minimum-functionality vs advanced encodings, 80% / 15%, 86.17 s vs 0.36 s, +33% coverage), November 2022: https://engineering.fb.com/2022/11/04/video-engineering/instagram-video-processing-encoding-reduction/
- Blog
Vladislav Vorotilov, Ilnur Shugaepov — Scaling the Instagram Explore recommendations system (four stages, Two Towers, cached user embeddings, ANN), August 2023: https://engineering.fb.com/2023/08/09/ml-applications/scaling-instagram-explore-recommendations-system/
Research:
- Paper
Subramanian Muralidhar et al. — f4: Facebook's Warm BLOB Storage System, OSDI 2014 (warm blobs, effective replication 3.6 → 2.8 or 2.1): https://www.usenix.org/conference/osdi14/technical-sessions/presentation/muralidhar
Design note: the architecture, code, thresholds, rates, sizes and dashboards here are an illustrative design for the problem, not a description of Instagram's internal systems beyond what Instagram and Meta have published.
Further Reading#
Related systems to study:
- Read
Twitter / X (Bonus Problem 10 in this series): fan-out on write versus read, and the celebrity merge, in depth
- Talk
YouTube (Bonus Problem 19): an upload pipeline, view counting and two-stage recommendations
- Talk
Netflix (Bonus Problem 6): delivering video from caches near viewers
- Read
WhatsApp (Bonus Problem 2): media sharing and delivery at messaging scale
Foundational concepts (this course):
- Read
Week 4, Day 4 — Feed Caching
- Read
Week 4, Day 5 — Multi-Tier Caching
- Read
Week 1, Day 1 — Partitioning Deep-Dive
- Read
Week 1, Day 4 — Hot Keys and Skew
- Read
Week 7, Day 3 — Query Processing and Relevance
- Read
Week 9, Day 4 — Right to Deletion
"Every photo is stored once; every feed is a choice of twenty out of thousands, made again each time you open the app."
- 0/7stages cleared
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.