Scale Interview
System DesignHard

Design a Stories Feature

Instagram / Snapchat System Design

Overview

Open Instagram (or Snapchat) and a row of circles sits at the top of the screen — the story tray. Each circle is a friend or account you follow who has posted a photo or short video in the last 24 hours; a colored ring means you haven't watched it yet. Tap one and it plays full-screen, then moves to the next person. Twenty-four hours after it was posted, a story simply vanishes — from the tray, from the app, and from the servers.

A story is a broadcast that expires. That places it between the two systems you may already know. Instagram's main feed is a broadcast that lasts forever (a post stays on your profile). A Snapchat snap is ephemeral but private (one photo to one friend, deleted after viewing). A story is both at once: one post shown to many viewers (like the feed) that disappears after 24 hours (like a snap). That combination is what makes it its own design problem.

Instagram vs Snapchat — the same design, one graph apart. Instagram Stories and Snapchat Stories are the same system-design problem; they differ only in the audience graph. Snapchat stories go to your mutual, bounded friends, so the tray is pure pull-at-read and there is no celebrity fan-out problem at all. Instagram stories go to your followers, and for a public figure that's tens of millions — so Instagram layers the public/creator fan-out case on top of the same core. We design the general system and call out where the two diverge: the graph drives the read path (Deep Dive 1) and the audience rules (Deep Dive 3); the write path, 24-hour expiry, and media pipeline are identical for both.

It is labeled "Hard" because the obvious answers to each part break at scale. If you push a new story into every follower's tray the moment it's posted, a public figure with 100M followers melts your write tier — the same celebrity fan-out problem the feed has. If you scan all stories at read time to build a tray, you can't keep up with hundreds of thousands of tray loads per second. If you forget the 24-hour clock, expired stories keep showing stale rings. And if you ignore privacy, a "close friends" story leaks to someone who wasn't meant to see it. A strong answer leans on one key asymmetry — you follow a bounded number of accounts, even though a celebrity is followed by millions — assembles the tray by pulling your followees' active stories at read time, expires stories with a read-time filter plus a TTL, and enforces audience rules per viewer.

In this walkthrough you'll scope the system, size it, model the data, design the API, draw the write / read / expiry paths, then go deep on tray assembly (fan-out), the 24-hour lifecycle, and privacy with per-viewer seen-state.

Out of scope (say so): the internals of media transcoding (covered in the Instagram feed breakdown), direct-message replies to a story, ranking the tray by ML affinity, ads between stories, and end-to-end encryption. We keep the core: post a 24-hour story to a chosen audience, load the tray, watch a friend's stories, and have everything expire reliably.

Functional requirements

  • Post a story. A user uploads a photo or short video that stays visible for 24 hours, choosing an audience (all followers, or a "close friends" subset).
  • Load the story tray. A user opens the app and sees which accounts they follow have an active, unseen story — respecting each story's audience.
  • Watch a story. A user plays another account's active stories in order; watched stories are marked seen (the ring turns grey), and the author can see who viewed their story.
  • Automatic expiry. Every story disappears from the tray and is deleted 24 hours after it was posted.

Out of scope (state it): DM replies and reactions, story highlights (saving a story permanently — a different, non-ephemeral flow), tray ranking beyond recency, and ads.

Non-functional requirements

  • Scalability. A public account's single story is watched by tens of millions of viewers within 24 hours, and the tray is loaded by every active user, constantly — both the write side (a celebrity posting) and the read side (everyone's tray) must scale horizontally.
  • Latency. The tray and the first frame of a story must load fast — target a few hundred milliseconds — because it's the first thing users touch. Posting feels instant; the media processes asynchronously.
  • Availability. The tray and playback must stay up under failures. A ring that is briefly stale (shows seen/unseen a second late) is acceptable; the app being down is not.
  • Consistency (eventual). A story appearing a second or two after it's posted, or lingering a second past expiry, is fine. But expiry must be reliable — no story is ever served past ~24 hours.
  • Durability. A posted story must not be lost before it expires; its media survives until its 24-hour TTL.

Estimations

State assumptions; the goal is to justify pull-at-read, the TTL lifecycle, and the seen-state store — not to be exact.

Rounded assumptions

  • DAU: ~500M. Assume ~40% post stories, ~2 each per day → ~400M stories/day400M / 86,400s ≈ ~5K stories/sec average, ~15K/sec at peak.
  • Tray loads are the hottest read. Every active user opens the app and reloads the tray many times a day. 500M × ~20 loads = ~10B tray loads/day → ~115K/sec average, ~300K/sec peak. This read path dominates.
  • Follow skew is the whole game. A typical user follows a few hundred to low thousands of accounts. But a celebrity is followed by tens to hundreds of millions. The set you read from is bounded; the set that reads you is not.
  • Media: photo ~1–2 MB, short video ~2–5 MB. 400M stories/day × ~2 MB ≈ ~800 TB/day flowing in — but with 24-hour retention, the resident footprint is a rolling ~1 PB, and deletes run at roughly the post rate (~5K/sec) as stories age out.
  • Seen-state is the heavy metadata. Each story view writes a "seen" mark. With billions of views a day, all of it TTL'd to 24 hours, the seen store is high-write and short-lived.

How the numbers affect the design

Signal from the numbers Design decision
You follow only a few hundred accounts Build the tray by pulling your followees' active stories at read time — the read set is bounded, so this is cheap.
A celebrity is followed by 100M+ You cannot fan-out a story into 100M trays on write — pull-at-read sidesteps the celebrity problem entirely.
~115K+ tray loads/sec Cache a small per-author "active story" summary so a tray load is a batch of cheap cache reads, not a database scan.
Everything expires at 24h; deletes ≈ writes Expire with a read-time expires_at filter (instant, logical) plus a native TTL / object-lifecycle for cleanup — never a scan.
Media ~1 PB rolling, viewed briefly Presigned direct upload to a blob store + CDN; short retention, so no cold-storage tier (see the feed breakdown for the pipeline).
Billions of "seen" marks/day A compact, per-viewer seen store, TTL'd with the story; the author's full viewer list is heavy for big accounts and gets capped.

Note

The one insight that reframes the whole problem: Stories favor pull-at-read, the opposite of the permanent feed. The feed fans out on write because assembling it on read means merging hundreds of followees' whole post histories. A story tray only asks "does each followee have one active story right now?" over your bounded following — a cheap read — so you don't need to pay the celebrity fan-out cost at all.

Core entities / data model

Five things are stored: the follow graph (and the close-friends list), the story (metadata + a media pointer), the seen state (who watched what), and a small per-author index that makes the tray read cheap.

Entity Key fields
User user_id, username, created_at
Follow (follower_id, followee_id), created_at — a directed edge
CloseFriend (user_id, friend_id) — the "close friends" audience subset
Story story_id, author_id, media_key (blob pointer), media_type, audience (public | close_friends), created_at, expires_at (= created_at + 24h)
StoryView (story_id, viewer_id)viewed_at — the seen mark and the author's "viewed by" list; TTL'd with the story

The Story row stores a blob key (media_key), never the bytes — the same rule as any media system. Its expires_at is the 24-hour clock: every read filters expires_at > now(), so a story leaves the tray the instant it expires, independent of when its bytes are physically deleted (Deep Dive 2). Stories are keyed and indexed by author_id, because every read asks "what are this author's active stories?"

The StoryView row does double duty: for the viewer, it's the "I've seen this" mark; for the author, the set of StoryViews for a story is the "viewed by" list. It carries the same 24-hour TTL as the story. For greying the ring, though, you don't scan a viewer's StoryViews on every tray load — you maintain a compact per-(viewer, author) last_seen_at cursor (the newest story that viewer has watched from that author), a cheap projection of those views, and compare it to the author's latest_at (Deep Dives 1 and 3).

The per-author active-story summary is worth naming as its own cached object: a tiny per-author record the tray reads instead of scanning that author's stories. Because audience is per-story (an author can have a public story and a close-friends one active at once), the summary keeps the newest active timestamp per audience tierpublic_latest_at and close_friends_latest_at — so a viewer resolves their own "newest visible story" by picking the tier they belong to. It's derived from the Story rows and cached hot (Deep Dive 1).

Where each lives. User / Follow / CloseFriend live in a sharded relational or graph store. Story metadata and StoryView seen-state live in a store with native per-row TTL and high write throughput (a wide-column store such as Cassandra, or DynamoDB), keyed by author_id. The per-author summaries are cached in Redis. Media blobs live in an object store (S3-style) with a lifecycle policy as the deletion backstop, served through a CDN.

Note

Ephemeral does not mean zero-trace. As with any deletion-heavy system, you typically keep a minimal metadata record (who posted, when, moderation flags) for a bounded window for safety and legal reasons after the media is gone. Say so in the interview — claiming the system keeps literally nothing is both wrong in practice and a red flag.

API design

Five operations carry the system: request an upload URL, post a story, load the tray, watch an author's stories, and read the viewer list.

Step 1 — Request a presigned upload URL

POST /api/stories/upload-url
Body:    { "media_type": "video", "file_size_bytes": 3145728 }
201 Created
Returns: { "upload_url": "https://s3.../...", "media_key": "u42/s991", "expires_at": "..." }

Returns a short-lived presigned URL. The client uploads the raw media straight to the blob store; the API never touches the bytes.

Step 2 — Post the story

POST /api/stories
Body:    { "media_key": "u42/s991", "media_type": "video", "audience": "close_friends" }
202 Accepted
Returns: { "story_id": "...", "expires_at": "<created_at + 24h>" }

Creates the Story row with expires_at = created_at + 24h and the chosen audience. Returns 202 — media processing is async, and there is no fan-out write (the tray is built on read). Notice how little happens here: posting a story is one small row insert, even for a celebrity.

Note what the body does not carry: the author comes from the auth token (never a client-supplied user_id — otherwise you could post as someone else), and the server stamps created_at/expires_at itself so the 24-hour clock can't be gamed by a client backdating or forward-dating the story.

Load the story tray

GET /api/stories/tray
200 OK
Returns: { "tray": [ { "author_id": "f1", "has_unseen": true, "latest_at": "..." }, ... ] }

Returns the caller's followees who have an active, unseen story they're allowed to see, newest-unseen first. This is the hot path — served from cached per-author summaries plus the caller's seen-state (Deep Dive 1).

Watch an author's stories

GET /api/stories/{author_id}
200 OK
Returns: { "segments": [ { "story_id": "...", "media_url": "https://cdn.../signed?exp=300s", "created_at": "..." }, ... ] }

Returns that author's active story segments in order, each with a short-lived signed CDN URL. The client marks each as seen as it plays (below). The server re-checks that the caller is in each story's audience.

Mark seen / read the viewer list

POST /api/stories/{story_id}/seen        → 204   (writes a StoryView for the caller)
GET  /api/stories/{story_id}/viewers     → 200 { "count": 40213, "sample": [ ... ] }   (author only)

seen records the StoryView that greys the ring. viewers returns the "viewed by" list to the author only; for large accounts it returns a count plus a bounded sample rather than tens of millions of rows (Deep Dive 3).

High-level architecture

Three flows matter, and they're deliberately decoupled: a write path (post a story), a read path (assemble the tray, then watch), and an expiry path (the 24-hour clock). We'll start with the simplest design that works — pull-at-read — and optimize it in the deep dives.

1. Write path — post a story

flowchart LR
    Client["Client"] -->|"1. upload-url"| API["Story Service"]
    API -.->|"presigned URL"| Client
    Client -->|"2. direct upload"| Blob[("Blob Store (S3)")]
    Client -->|"3. POST /stories"| API
    API -->|"insert Story row<br/>(by author, expires_at = +24h)"| Meta[("Story Store<br/>(TTL, by author)")]
    API -->|"refresh summary"| Sum[("Per-author summary cache")]
  1. The client asks for a presigned URL and uploads the raw media directly to the blob store — the API is never in the byte path.
  2. The client calls POST /stories. The Story Service writes one Story row, keyed by author_id, with expires_at 24 hours out.
  3. It refreshes that author's cached active-story summary — bumping the newest-story timestamp for the audience tier(s) the new story is visible to (a close-friends story bumps only close_friends_latest_at; an all-friends story bumps public_latest_at, which close friends also see) — and refreshes the entry's cache TTL (kept comfortably past the story's expiry, so it never evicts mid-window). That's the whole write — no fan-out to followers.

The point to make out loud: posting is cheap and the same for everyone. A normal user and a 100M-follower celebrity both do one row insert plus a summary refresh. All the scaling difficulty moves to the read side, where it's cheaper to handle.

2. Read path — tray, then watch

flowchart LR
    Client["Client"] -->|"GET /tray"| Tray["Tray Service"]
    Tray -->|"who do I follow?"| Graph[("Follow graph")]
    Tray -->|"batch: active-story summaries"| Sum[("Per-author summary cache")]
    Tray -->|"my seen marks"| Seen[("Seen store (by viewer)")]
    Tray -.->|"unseen, allowed rings"| Client
    Client -->|"GET /{author}"| Play["Story Service"]
    Play -->|"signed URLs"| CDN["CDN"]
  1. On tray load, the Tray Service reads the caller's following list (bounded — a few hundred to low thousands).
  2. It does a batch lookup of those authors' active-story summaries from cache; a summary is a cache-aside optimization, not the source of truth, so a miss falls back to that author's rows in the Story store and re-warms the cache (a missing entry means "not cached," not "no story"). It then drops any author with no unexpired active story (expires_at > now()).
  3. It checks the caller's seen-state to mark each remaining ring seen/unseen, and filters by audience (only stories the caller is allowed to see).
  4. To watch, the client calls GET /{author} and streams each segment's media from the CDN via a short-lived signed URL, marking each seen as it plays.

3. Expiry path — the 24-hour clock

A story must leave the tray and be deleted 24 hours after it's posted, and that happens two independent ways:

  1. Logically, at read time. Every read filters expires_at > now(), so the instant a story turns 24 hours old it stops appearing in trays and can't be watched — no delete needs to have run yet.
  2. Physically, in the background. A native store TTL on the Story/seen rows and an object-lifecycle policy on the media reclaim the storage later, with no scan.

The read-time filter is what makes expiry correct (nothing expired is ever served, even if cleanup is behind); the TTL/lifecycle is what reclaims space. Deep Dive 2 explains why you need both.

4. Combined architecture

flowchart LR
    Client["Client"] --> API["Story / Tray Service"]
    API -->|"post: insert + refresh"| Meta[("Story Store (TTL, by author)")]
    API --> Sum[("Per-author summary cache")]
    API -->|"tray: bounded following"| Graph[("Follow graph")]
    API -->|"seen marks"| Seen[("Seen store (by viewer, TTL)")]
    Client -->|"direct upload"| Blob[("Blob Store")]
    Client -->|"watch: signed URL"| CDN["CDN"]
    CDN -.->|"origin fetch"| Blob
    Reaper["TTL / lifecycle"] -.->|"reclaim expired"| Meta
    Reaper -.->|"lifecycle delete"| Blob

The write, read, and expiry paths don't block each other. Posting is a small insert; the tray is a bounded pull over cached summaries; expiry is a read-time filter plus a background TTL that reclaims storage. The follow graph and blob store are shared with the main app; the story-specific pieces are the TTL'd story/seen stores and the per-author summary cache.

Tip

Lead with the pull-at-read tray for a normal user, then stress it: "what about someone I follow who has 100M followers?" Show that pull-at-read already handles it (the celebrity's post is one row; my tray only reads their summary), then layer on the read-side optimizations. Don't open with fan-out-on-write — it's the wrong default for stories.

Deep dives

1. Assembling the story tray — pull, push, or hybrid

The tray is the hottest read in the system, and how you build it is the central decision. The question: when Alice opens the app, how do you produce "the accounts I follow that have an active, unseen story"? Three approaches, worst to best.

Fan-out on write — push each story into every follower's tray (avoid — the celebrity problem)

When someone posts a story, immediately write it into a precomputed tray cache for every one of their followers, so each user's tray is ready to read.

Worked example — a public account with 50M followers posts one story:

post story → write "new story from A" into 50,000,000 follower tray caches
  → 50M cache writes for ONE post
  → most of those followers won't open the app before it expires
  • Pro: the tray is a single cache read per user at load time.
  • Con: a post by a high-follower account is a fan-out storm — tens of millions of writes for one story, most of them wasted because only a fraction of followers watch. It's the feed's celebrity problem, but worse, because a story is gone in 24 hours so the write amortizes over almost nothing.
  • Verdict: avoid as the default. Stories don't need it — the read set (your following) is small, so you never have to pay this write cost.
Pull at read — build the tray from your bounded following (recommended — the default)

Don't precompute anything per-follower. When Alice loads the tray, read her following list (bounded — she follows, say, 400 accounts), then look up each of those authors' active-story summary and keep the ones with an unseen, in-audience, unexpired story.

The trick that makes this cheap at 115K+ tray loads/sec is the per-author summary: a tiny cached record per author — the newest-story timestamp per audience tier (public_latest_at, close_friends_latest_at; why two, not one, is Deep Dive 3) — refreshed on post. Nothing has to refresh it on expiry: the tray derives "has an active story?" by comparing the viewer's tier timestamp to now() at read time (latest_at > now − 24h) — a present-but-stale entry simply yields no ring, no delete or flip required. The entry carries only a generous memory-reclamation TTL (comfortably past the story's expiry, never exactly at it — evicting right at expiry would just make every follower's next load miss and fall back to the store). A genuine miss (cold or evicted) falls back to the Story store and re-warms. A tray load becomes one batched multi-get of ~400 small cache entries, not a scan of anyone's stories.

Worked example — Alice follows 400 accounts:

GET /tray (Alice):
  following(Alice) = 400 author_ids
  MGET summary[author] for those 400          ← one batched cache read
  for each author A:
    visible_latest = newest story A that Alice may see   (tier + audience: Deep Dive 3)
    keep A if visible_latest exists AND not expired (> now-24h)
             AND visible_latest > Alice.last_seen[A]      ← unseen
  → ~a few dozen rings, newest-unseen first
  • Pro: no fan-out write, so posting is O(1) even for a celebrity; the read is bounded by your following, which is small; nothing is wasted on followers who never open the app.
  • Con: each tray load fans out to N summary reads (N = accounts you follow). Batched and cache-backed this is cheap, but a user who follows tens of thousands makes N large — see the hybrid below.
  • Verdict: the right default. It turns the celebrity problem into a non-issue and matches how stories are actually read.
Hybrid — cache the assembled tray for heavy-following or very-active users (situational)

For the small fraction of users who follow tens of thousands of accounts, N summary reads per tray load gets expensive at their refresh rate. For those, materialize and cache the assembled tray (the list of rings) and update it incrementally: when someone they follow posts, push a lightweight "new active story from A" invalidation into their cached tray. This is fan-out on write again — but only to active, heavy-following users, and only a tiny pointer, not the media.

  • Pro: turns a large per-load pull into a single cached read for the users who need it most.
  • Con: reintroduces a (small, targeted) fan-out and a cache to keep consistent with posts and expiries; only worth it for a minority of accounts.
  • Verdict: a targeted optimization on top of pull-at-read, not a replacement. Apply it by user behavior (following count, activity), and keep pull-at-read as the baseline everyone falls back to.

Caution

Do not make the tray scan each author's stories, or scan a global "active stories" table, on every load. At hundreds of thousands of loads per second that melts the store. Read a small per-author summary (cached), and let the caller's bounded following limit the work.

The one hotspot pull-at-read creates. A celebrity's summary is read by every one of their millions of followers' tray loads — a single hot cache key. But this is far easier to absorb than fan-out-on-write's millions of writes: replicate the hottest summaries across cache nodes, or cache them for a few seconds locally on each tray server, so a hot author is served from many copies. Pull-at-read trades an unbounded write problem for a bounded, cacheable read problem — the better trade.

Seen vs unseen. The colored ring means "unseen." Track it per viewer as a compact last-seen timestamp per author (Alice.last_seen[author]): a ring is unseen when the author's latest_at is newer than Alice's last-seen mark for that author. That's one small value per (viewer, author) you already touch, cheaper than a row per (viewer, story), and it TTLs away naturally.

Tray vs. player — two read tiers. The summary answers only the tray's question — is there an unseen ring? — so it deliberately holds no story list. When the candidate taps a ring, a separate GET /{author} fetches that one author's full active-segment list (each story's id, order, created_at, and signed media URL) — which is what drives the player and its segmented progress bar (one bar per segment) and the actual media. That heavier read runs only for the handful of authors the user actually opens, never for all ~400 followees on every tray load — which is exactly why the tray summary is kept tiny. It's the standard list-vs-detail split: a cheap projection for the list (the tray), a full fetch on tap (the player). (If your tray ring is drawn as segmented arcs, the summary needs just one more cheap field — a per-tier segment_count — not the whole list; the list still loads only on tap.)

2. The 24-hour lifecycle — expiry without scans

A story must be gone 24 hours after it's posted — out of the tray, unplayable, and eventually deleted from storage. Doing that with a periodic "delete expired stories" scan doesn't survive this scale (deletes run at the post rate, ~5K/sec, and a sweep lags and competes with live traffic). The clean design separates logical expiry (instant, at read time) from physical reclamation (background), the same two-mechanism shape the ephemeral-messaging breakdown uses for deletion.

Logical expiry — a read-time filter. Every story carries expires_at = created_at + 24h, and every read filters expires_at > now(). The moment a story crosses 24 hours, it stops appearing in trays and can't be fetched — no delete needs to have run yet. This makes expiry instant and correct regardless of how far behind physical cleanup is. The per-author summary works the same way, and this is the subtle part: no service monitors expirations to un-set it. The summary stores the newest story's timestamp, and the tray derives "has an active story?" by comparing it to now() — so an author whose newest story is past 24h just produces no ring, with nothing flipped. The summary entry needn't be evicted at expiry — a present-but-stale timestamp already yields no ring through the comparison — so it just carries a generous memory-reclamation TTL (well past 24h). The only proactive writer is the post path; expiry itself is the read-time comparison, and a genuine cache miss falls back to the Story store.

Physical reclamation — native TTL + lifecycle. To actually free storage, give every Story and StoryView row a store-native TTL at expires_at, and give the media blob an object-store lifecycle policy at the same age. Both fire in the background with no application involvement and no scan.

flowchart LR
    Post["Post story<br/>expires_at = +24h"] --> Meta[("Story row<br/>TTL @ expires_at")]
    Post --> Blob[("Media blob<br/>lifecycle @ 24h")]
    Read["Every tray / watch read"] -->|"filter expires_at > now"| Meta
    Read -. "expired ⇒ invisible instantly" .-> Gone["not shown"]
    TTL["Store TTL / object lifecycle"] -.->|"reclaim in background"| Meta
    TTL -.->|"delete media"| Blob

Why both. The read-time filter guarantees correctness — a story is never served past 24 hours even if cleanup is minutes behind. The TTL/lifecycle guarantees storage is reclaimed without a scan, even for stories no one reads again. Deletion stays idempotent (deleting an already-gone blob is a no-op), so overlap between the two is harmless.

The write / media side. Posting is a presigned direct upload followed by async processing (transcode, thumbnail) off the post path — the same pipeline as the feed, with one difference: because a story lives only 24 hours and is watched a bounded number of times, you keep it in a single hot tier with short-lived signed URLs and skip cold-storage tiering entirely.

Warning

Don't rely on a cron job to pull expired stories out of trays — that's what causes stale rings. Make expiry a read-time expires_at filter so it's instant, and use TTL/lifecycle only to reclaim storage in the background. The filter is the correctness guarantee; the TTL is the cleanup.

3. Privacy and seen-state — friends vs public, per viewer

A story isn't always public. Instagram has close friends (a curated subset who see certain stories), users can hide their story from specific people, and a private account's story is visible only to approved followers. Every one of these is a per-viewer visibility decision, and because the tray is built at read time, that's exactly where you enforce it — cleanly.

Audience enforcement at read. Each Story carries an audience tag. When assembling viewer V's tray (or when V tries to watch author A), include A's story only if all hold:

V follows A                                  (or A is public / V is approved)
AND (A.story.audience == public
     OR (audience == close_friends AND V ∈ A.close_friends))
AND V ∉ A.hidden_from

Because A's close-friends list and block list are small and cacheable, this is a couple of cheap set-membership checks per candidate author — negligible on top of the tray pull. This is where the summary's per-tier timestamps (Deep Dive 1) pay off: a plain follower resolves their newest-visible story against public_latest_at, a close friend against close_friends_latest_at (the newer of the two tiers), and anyone in hidden_from is simply dropped from that viewer's tray.

Worked example — why the tiers can't collapse to one timestamp. A posts story2 to all friends at t2, then story1 to close friends only at t1 (t1 > t2), both still active. So public_latest_at = t2 and close_friends_latest_at = t1. Now a plain friend B (not a close friend): B resolves against public_latest_at = t2, so B does see A's ring, marked unseen against t2 — and when B opens A, the watch call re-filters by audience and returns only story2. A close friend resolves against t1 and sees story1. If the summary cached a single "latest = t1, audience = close_friends" instead, B would wrongly get no ring at all even though story2 is visible to them — which is why the summary keeps a timestamp per tier. (This tier trick only covers a fixed, small set of audiences; Snapchat-style custom stories — an arbitrary friend subset per story — can't be cached per tier, so you resolve the viewer's newest-visible story against the actual story rows instead.)

This is a hidden advantage of pull-at-read: visibility is decided freshly for each viewer at read time, so a close-friends story can never sit in a precomputed cache that the wrong person reads. (A fan-out-on-write design would have to fan out respecting audience, and re-fan-out when the close-friends list changes — another reason push is the wrong default here.)

Seen-state and the viewer list. Two projections of each view event:

  • The viewer side is the compact last_seen[author] cursor from Deep Dive 1 — cheap, per viewer, TTL'd.
  • The author side is "who viewed my story," which is the set of StoryViews. For a normal account that's a short, exact list. For a public account watched by tens of millions, materializing and returning that list is itself a scale problem: store the views append-only, return a count plus a bounded sample rather than the full set, and accept that the count may be approximate. Instagram in fact caps the detailed viewer list for large audiences for exactly this reason.
flowchart LR
    V["Viewer watches A's story"] -->|"POST /seen"| SV[("StoryView<br/>(story, viewer) TTL 24h")]
    SV -->|"viewer side"| Cur["last_seen[A] cursor<br/>→ greys the ring"]
    SV -->|"author side"| List["'viewed by' list<br/>exact if small,<br/>count + sample if huge"]

Note

Screenshot detection is best-effort, exactly as in the ephemeral-messaging design: you can notify the author that a viewer screenshotted, but you cannot prevent it — the pixels are on the viewer's screen. Present it as a deterrent and a notification feature, never a guarantee.

Caution

Enforce audience on every read (tray and watch), not just when building the tray. A viewer who has the author's URL must still fail the audience check on GET /{author}, or a close-friends or hidden story leaks to someone with a guessed or shared link.

Failure modes

Every derived structure here is rebuildable; the only thing that must not be lost before it expires is the posted Story row and its media.

  • Media upload fails. The Story row is only created after POST /stories, and the client uploads first — a failed upload means no story is posted, and the client retries with the same media_key (idempotent). No half-posted story.
  • Story Service down. Posting fails and the client retries idempotently; tray reads degrade to the last cached summaries. Run it replicated and partitioned by author_id so one node's loss stalls only its authors.
  • Summary cache stale or lost. It's a derived cache, not the source of truth — a miss falls back to the author's Story rows (and re-warms). Correctness never depends on it: the read-time expires_at filter and the timestamp comparison keep expired stories out regardless, so the only cost of a stale or cold summary is an extra Store read, never a wrong ring.
  • Seen-state write lost. A dropped POST /seen just means a ring shows unseen again — harmless; the user re-watches. Seen-state is a convenience signal, not a correctness one.
  • Expiry/TTL delayed. Physical deletion running behind is invisible to users because the read-time expires_at filter already hides the story; the TTL only reclaims storage. Logical expiry never depends on the reaper being on time.

The through-line: the read-time expires_at filter is the correctness backstop for expiry, and the durable Story row is the one thing that must survive until 24 hours pass. Everything else — summaries, trays, seen-state — is derived and rebuildable.

Tradeoffs & bottlenecks

  • Pull-at-read vs fan-out-on-write. Stories pull at read because your following is bounded, which sidesteps the celebrity fan-out cost that the permanent feed pays — the opposite default from the feed. The price is N cached summary reads per tray load, mitigated by batching and, for heavy-following users, a targeted cached tray.
  • Logical vs physical expiry. A read-time expires_at filter makes expiry instant and correct; native TTL/lifecycle reclaims storage lazily. Decoupling them is what avoids an expensive delete-scan at post-rate throughput.
  • Seen-state granularity. A per-(viewer, author) last-seen cursor is far cheaper than a per-(viewer, story) row and is enough to grey rings; the exact per-story viewer list is the costly direction and gets capped for big accounts.
  • Privacy at read vs at write. Enforcing audience at read keeps a private story out of any shared cache and adapts instantly to list changes; it costs a cheap set check per candidate author. A fan-out design would have to re-fan-out on every audience change.
  • Media short-retention. 24-hour life means a single hot tier and short signed URLs — simpler than the feed's cold-tiering, at the cost of lower CDN reuse (fine, since a story is watched a bounded number of times).

Extensions if asked

Add only the extension that changes the design discussion (these are standalone; there's no dedicated guide for each yet unless linked):

  • Story highlights. Saving a story past 24 hours turns an ephemeral object into a permanent one — you copy it out of the TTL'd store into permanent storage and drop the expires_at, converging on the feed's media model.
  • Public / creator stories at massive scale. A creator's story watched by tens of millions stresses the viewer-list and seen-state paths hardest; you lean further on counts-plus-sample and approximate view counts.
  • Ranking the tray. Ordering rings by affinity/recency rather than plain recency is an ML ranking problem layered on top of assembly — see the ranking discussion in the ads-ranking breakdown.
  • Replies and reactions. A reply to a story is really a direct message keyed to the story — it hands off to the chat/messaging design.
  • Ads between stories. Inserting sponsored stories into the tray is an ad-serving/insertion problem on top of the read path.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Pull-at-read as the default, with the explicit reasoning that your following is bounded even though a celebrity's followers are not — so stories dodge the feed's fan-out-on-write cost.
  • A per-author active-story summary cached hot, so the highest-QPS read (the tray) is a batched cache multi-get, not a scan.
  • 24-hour expiry as a read-time expires_at filter plus a TTL/lifecycle backstop — never a delete-scan.
  • Per-viewer audience enforcement at read (public / close friends / hidden), and noticing that pull-at-read makes privacy clean.
  • Presigned direct upload + short-lived signed URLs, with async media processing, and short single-tier retention.
  • Seen-state as a compact per-author cursor, and recognizing the author's viewer list is the costly, cap-it-for-big-accounts direction.

Before you finish, do a quick mistake check:

  • Did you build the tray by pulling your bounded following's active stories, instead of fanning every story out to all followers on write?
  • Did you cache a small per-author summary so the tray load isn't a scan?
  • Did you expire stories with a read-time filter (instant) and a TTL/lifecycle (cleanup), not a cron scan?
  • Did you enforce audience per viewer on every read, including the watch call — not just when building the tray?
  • Did you use presigned upload + short signed CDN URLs, keeping API servers out of the byte path?
  • Did you keep seen-state compact and recognize the celebrity viewer-list as the part that must be capped or approximated?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →