The 100 problems, solved / Social and feeds / 019 · Design the stories feature (24-hour posts)

Design the stories feature (24-hour posts)

Ephemeral posts that expire on the dot, with view receipts and a fast tray.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements

Functional

  • A user posts a photo or short video as a story; it is visible to their followers for exactly 24 hours, then disappears.
  • Followers see a tray of avatars of people with active stories, unseen first; tapping plays that person's stories in order.
  • The author sees who viewed each story; replies go to direct messages (out of scope).
  • Out of scope: highlights (stories kept permanently), ads, close-friends lists.

Non-functional

  • 500 million daily users; 300 million stories a day; average user follows 300 accounts and opens the tray 10 times a day.
  • Tray load under 150 ms; a story must start playing within 300 ms of a tap; the next story is prefetched.
  • Expiry within a minute of the 24-hour mark, everywhere, including caches and CDN.
  • View counts are per viewer exactly once; storage for expired media is reclaimed within a day.
Back of the envelope

Stories: 3 × 108/day ≈ 3,500/s average, 15,000/s at peak; at 1.5 MB average that is 450 TB/day of media, all of it deletable after 24 h, so the live media set is under 1 PB.

Tray loads: 5 × 108 × 10 = 5 × 109/day ≈ 58,000/s average, 200,000/s at peak; each must answer "which of my 300 followees have an active story?"

Views: a story is seen by 20 percent of followers on average; 300 million stories × 60 views = 1.8 × 1010 view records a day, 16 bytes each, 290 GB/day, all deletable after 24 h.

2. Core entities

  • Story: id (time-ordered), author, media id, created-at, expires-at (created + 24 h), deleted flag.
  • Active stories per author: author → ordered list of story ids with expiry times.
  • Story tray entry: viewer → list of (author, latest story time, has unseen).
  • View: (story id, viewer id, time).

3. API

  • POST /stories body: media id → story id and expiry; media uploaded beforehand to a signed URL.
  • GET /stories/tray → authors with active stories, ordered unseen first then recency, with avatar URLs.
  • GET /stories/author/{id} → that author's active stories with media URLs and the viewer's seen marker.
  • POST /stories/{id}/view → records a view, idempotent per viewer.
  • GET /stories/{id}/viewers?cursor= → author only.

4. High-level design

ClientsEdgeServicesDataplayadd storymark author activecheck still livedelete mediaAppCDN (media, short TTL)Load balancerStory serviceTray serviceExpiry sweeperFan-out workersActive stories(KV with TTL)Tray lists(per viewer, TTL)View records(TTL table)Media (object storage,lifecycle 1 day)

Posting a story and seeing one:

  1. The app uploads media to object storage and calls POST /stories. The story service writes the story with expires_at into the active-stories store under the author's key, with a TTL of 24 hours on the entry.
  2. Fan-out workers add the author to each follower's tray list (a small set per viewer, also with TTL), as in the feed design's push path; celebrity authors are pulled at tray-load time instead.
  3. The tray service reads the viewer's tray list, drops authors whose stories have expired, sorts unseen first, and returns avatars. Tapping an author fetches their active list and media URLs; the client prefetches the next story's media from the CDN.
  4. Views are recorded idempotently; the sweeper deletes media past expiry and the stores drop expired entries on their own.

5. Deep dives

How does "exactly 24 hours" work across caches, CDN and three stores?

Expiry is a property of the data, not of a job: every story row carries expires_at, and every read path filters on it, so a story is invisible the second it expires even if nothing has deleted it yet. The stores then clean up in their own time: the active-stories and tray entries have native TTLs; the view table is partitioned by day and old partitions are dropped; media sits in a bucket with a one-day lifecycle rule plus a sweeper for precision. The CDN is the awkward part because a cached media file can be fetched by URL after expiry; so media URLs are signed with an expiry equal to the story's, and the CDN TTL is capped at the remaining lifetime, which makes an expired story's URL fail at the edge without a purge. Deleting a story early is a flag flip plus a CDN purge of that one URL.

The tray at 200,000 loads a second: push or pull?

Pull means reading 300 followees' active lists per tray load: 60 million reads a second at peak, all cache hits perhaps, but a lot of them. Push (fan-out on write) means each new story touches 300 tray lists on average, 4.5 million small writes a second at peak, and the tray load is one read. Push wins here because stories are few relative to tray loads, and because the tray entry is tiny (author id, latest time). Celebrities are pulled, as in the feed. A subtle point interviewers like: the tray must drop authors whose stories have all expired, which push cannot know in advance, so the tray service re-checks the active list for each author with a batched multi-get; the lists are small and in memory, so the check is cheap.

View receipts without a hot row

A viewer's "seen" marker is per (viewer, author): a small record saying the latest story id seen, updated on each view, so the tray can sort unseen first with one read per author. The author-facing viewer list is an append-only table partitioned by story id, read with a cursor; the count is maintained by a sharded counter because a celebrity's story can receive thousands of views a second. Idempotency comes from the (story, viewer) key: a duplicate view request is a no-op, which also makes client retries safe on bad connections.

Media that lives a day: what changes versus permanent posts?

Everything is cheaper and some things are simpler. No long-term tiering, no dedup, no reprocessing backfills; a single rendition per device class is enough because a story is watched once. Transcoding must be fast rather than optimal, since a story that takes five minutes to process has lost a sixth of its usefulness; a short clip is processed in seconds on the upload path or with a fast-lane queue. The live media set is under 1 PB, which fits in a hot object-storage tier in a few regions with the CDN in front; replication is to one other region only, because the loss of a day's stories, while bad, is not the permanent loss a post deletion would be, and the requirements should say so.

Playback: starting within 300 ms of a tap

The tray response already carries, for each author, the media URL and dimensions of their first story, so a tap needs no further API call before playback starts. The client prefetches the first segment of the next story in the tray while the current one plays, and videos are served as short segments with a small first segment so the player can start after a few hundred kilobytes. Signed URLs are generated when the tray is built, with the story's remaining lifetime as their expiry, so prefetched media stays valid for as long as the story does and no longer.

6. What a strong answer sounds like

  • Say that expiry is data, not a job: filter on expires_at everywhere and let TTLs, lifecycle rules and signed URLs do the cleanup.
  • Do the tray arithmetic and choose push with a celebrity pull, then handle the "all expired" re-check.
  • Design view receipts as per-pair seen markers plus a sharded count.
  • Point out what a 24-hour lifetime simplifies and what it demands (fast processing).
  • Expect follow-ups on close-friends visibility (a per-story audience list checked at read), on highlights (copy to permanent storage), and on ordering the tray by a relevance model.
  • Common mistake: relying on a cron job to delete at the 24-hour mark and a CDN purge for every story.

02Where this question was reported