TL;DR

1. The problem: why is ephemeral content its own design problem?

Instagram launched Stories in 2016, and within a few years publicly reported around 500 million daily Stories users. Stories look like "posts that disappear", but their access pattern is very different from feed posts:

🧠 Analogy Stories are like a bakery's daily specials board. Each morning items are chalked up; at closing time they're wiped off β€” but the baker keeps a notebook (the archive) and frames a few favourites on the wall (highlights). Regulars glance at the board and the bakery remembers which specials each regular already tried (seen-state), and the baker gets a list of who looked at each item (viewer list). Wiping the board is easy; the hard part is that ten thousand customers look at the board every second, and some specials (a celebrity's) draw a crowd so large the queue blocks the door (hot partition).

2. Requirements and numbers

2.1 Functional requirements

⏳ 24-hour expiry

A story disappears from everyone's view exactly 24 h after posting.

🎞️ Stories tray

A ranked row of accounts with active stories; unseen first; tap to play an author's stories in order.

πŸ”΅ Seen / unseen

Coloured ring if the viewer has unseen stories from that author; playback resumes at the first unseen one.

πŸ‘€ Viewer list

The author sees who viewed each story and how many (for a limited period).

πŸ’¬ Replies & reactions

Reply or send a quick emoji; it arrives as a direct message referencing the story.

⭐ Highlights

Pin stories to the profile permanently, grouped into named collections.

πŸ’š Close friends

Post to a hand-picked audience; also "hide story from" specific people.

2.2 Non-functional requirements

2.3 Back-of-the-envelope (assumptions)

Assume 500M daily story viewers; 100M of them post, averaging 4 story items each; each viewer watches ~40 story items per day and opens the app ~10 times.

New stories/day   = 100M Γ— 4                 = 400M   β†’ ~4,600/s avg  (~14k/s peak)
Story views/day   = 500M Γ— 40                = 20B    β†’ ~230,000/s avg (~700k/s peak)
Tray loads/day    = 500M Γ— 10                = 5B     β†’ ~58,000/s avg
View records      = 20B Γ— ~24 bytes (story_id, viewer_id, ts)  β‰ˆ 480 GB/day before replication
Media ingest      = 400M Γ— ~3 MB (photo/video mix)              β‰ˆ 1.2 PB/day

Two striking facts: views outnumber new stories 50 : 1, and every view is a write. The design must make view-recording cheap and spread out. See capacity estimation.

3. Data model

stories              PARTITION KEY author_id   CLUSTERING story_id DESC   (TTL 24h on the "active" copy)
  author_id | story_id (time-sortable 64-bit) | media_id | media_type | audience | created_at | expires_at
            |                                  |          |            | ALL / CLOSE_FRIENDS

story_archive        PARTITION KEY author_id   CLUSTERING story_id DESC   (no TTL β€” private archive)
highlights           PARTITION KEY owner_id    CLUSTERING highlight_id  β†’ title, cover_media, [story_ids...]

story_views          PARTITION KEY (story_id, bucket)  CLUSTERING viewer_id     (TTL ~48h)
story_view_counts    story_id β†’ approximate count (counter / aggregated in a stream)

seen_watermark       (viewer_id, author_id) β†’ last_seen_story_id   (Redis hash, TTL 24h)
tray_inbox           viewer_id β†’ sorted set { author_id : latest_story_ts }   (Redis, entries >24h trimmed)

close_friends        PARTITION KEY owner_id  CLUSTERING friend_id
story_hidden_from    PARTITION KEY owner_id  CLUSTERING viewer_id

4. Making stories expire: TTL implementations

TTL (time to live) means "this data stops existing (or stops being visible) after a set duration". There are several places to enforce it, each with different guarantees.

MechanismHow it worksStrengthsWeaknesses
Cassandra TTLINSERT … USING TTL 86400; each cell carries an expiry. Expired cells are ignored on read and later turned into tombstones and purged during compaction.Automatic, per-row; with TimeWindowCompactionStrategy whole SSTables of expired data are dropped cheaply.Tombstones linger until gc_grace_seconds passes and compaction runs; mixing TTL and non-TTL data in one table hurts compaction; TTL is fixed at write time.
Redis EXPIRESET key … EX 86400 or EXPIRE key 86400. Keys are removed lazily when accessed after expiry and actively by a background sampler.Precise from the reader's point of view (an expired key is never returned); perfect for caches, tray sets and seen-state.Only key-level, not per member of a set/sorted set; memory only; not a source of truth.
Query-time filteringEvery read adds WHERE story_id > id_at(now βˆ’ 24h) (or created_at > now βˆ’ 24h).Exact to the millisecond regardless of cleanup lag; works in every store; trivial to reason about.Reclaims no storage; every read path must remember to apply it.
Background sweeperA scheduled job (or delayed queue) finds expired stories and deletes/archives them, purges caches, removes tray entries, emits StoryExpired.Handles derived data in many systems; can do side effects (archive, notify, analytics).Runs late if behind; must be idempotent; another moving part.
Object storage lifecycle rulesBucket rules like "expire objects under stories/ephemeral/ after 2 days".Zero-effort, cheap reclamation of petabytes of media.Day-level granularity and asynchronous processing (can lag hours) β€” useless for exact 24 h visibility; dangerous if the object is also needed by a highlight.

4.1 Why combine them

No single mechanism is both exact and complete. The robust design layers them, each doing what it's good at:

t = 0 postedt = 24 ht = 48 h~72 h Visible: query filter id > id_at(nowβˆ’24h) Cassandra active row (TTL 24 h) tombstone β†’ compaction Redis tray entry / seen-state (EXPIRE) sweeper: caches, StoryExpired Media blob β†’ lifecycle rule (if not archived/highlighted); viewer list TTL 48 h
Layered expiry: the query filter makes visibility exact at 24 h; TTLs, sweepers and lifecycle rules reclaim storage afterwards, at their own pace.
  1. Correctness comes from the query-time filter on every read path (tray, author's story list, story-by-ID), plus short-lived signed media URLs so a cached URL can't be replayed for days.
  2. Storage reclamation comes from Cassandra TTL (in a table dedicated to TTL'd data, with time-window compaction) and Redis EXPIRE.
  3. Side effects and derived data (cache purges, tray cleanup, analytics events) come from the sweeper, driven by a delayed queue or a time-bucketed index (expiring_at_hour β†’ [story_ids]).
  4. Media is reclaimed by lifecycle rules on an ephemeral prefix β€” only for stories not kept in the archive or a highlight (section 9).
⚠️ Clock skew "Now βˆ’ 24 h" must be computed from a trustworthy clock. Servers use NTP-synchronised clocks and the story ID's embedded timestamp (assigned server-side), never the client's device time. A few hundred milliseconds of skew is acceptable; minutes are not.

5. Generating the stories tray

The tray is the row of circular avatars at the top of the home screen. Building it means answering, fast: "Of the hundreds of accounts I follow, which have active stories I'm allowed to see, in what order?" This is a small version of the news feed problem, and the same three options apply.

5.1 Pull, push or hybrid

ApproachHowCost
Pull (fan-out on read)On tray open, fetch the viewer's following list (say 500 accounts) and look up "latest active story" for each.500 lookups Γ— ~58k tray loads/s β‰ˆ 29M lookups/s. Cheap writes, very expensive reads.
Push (fan-out on write)On StoryCreated, workers add author_id β†’ timestamp into each follower's tray inbox (Redis sorted set).Tray read = one small sorted-set read. But a celebrity with 300M followers creates 300M writes per story.
Hybrid ⭐Push for normal authors; for authors above a follower threshold, keep a global "active stories of celebrity X" entry and merge it in at read time for the few celebrities each viewer follows.Bounded write amplification and cheap reads.
Stories servicestory saved KafkaStoryCreated Tray fan-outworkers Graph servicefollowers (paged) Tray inbox per viewer (Redis)ZSET author_id β†’ latest story tstrim entries older than 24 h Celebrity active-stories cacheread-time merge (no fan-out) πŸ“± Vieweropens app Tray buildermerge, filter, rank Seen watermarks + affinityunseen first, ML score
Write path (top): StoryCreated is fanned out into followers' tray inboxes. Read path (bottom): the tray builder merges the inbox with celebrity stories, filters and ranks.

5.2 The read path

def build_tray(viewer):
    cutoff = now() - 24h
    candidates  = redis.zrangebyscore(f"tray:{viewer}", cutoff, "+inf")          # pushed authors
    candidates += [c for c in followed_celebrities(viewer)                        # pulled authors
                   if celeb_latest_story_ts(c) > cutoff]
    candidates  = [a for a in candidates if can_view_any_story(viewer, a)]        # privacy re-check
    watermarks  = redis.hmget(f"seen:{viewer}", candidates)
    for a in candidates:
        a.unseen = latest_story_id(a) > watermarks[a]
    ranked = rank_by_affinity(viewer, candidates)                                 # ML model
    return [viewer.own_story] + sorted(ranked, key=lambda a: (not a.unseen, -a.score))

Typical sizes: a viewer follows a few hundred accounts, of which maybe 30–100 have active stories β€” a small candidate set, so ranking it per request is affordable.

5.3 Ranking by affinity

The tray order is personalised. A ranking model (see Analytics & ML) predicts how likely the viewer is to watch and engage with each author's stories, using features such as:

The ordering rule is roughly unseen before seen, then by predicted interest. Affinity features are precomputed offline/in streams and cached per (viewer, author) pair so scoring is cheap at request time.

6. Tracking seen-state at scale

Every view must flip a ring from coloured to grey and make playback start at the right story. At ~230k views/s, storage and write cost matter. Four designs:

DesignStoresSize per viewer/dayExact?Notes
Per-viewer set of story IDsseen:{viewer} = {story_id, …}~40 Γ— 8 B β‰ˆ 320 B (+ overhead)YesSimple; one write per view; Redis set with 24 h EXPIRE
Per-author watermark ⭐seen:{viewer}[author] = last_seen_story_id~1 entry per author watched β‰ˆ 16 B eachYes, for in-order viewingWorks because an author's stories are played oldestβ†’newest and new ones only append
BitmapsPer (viewer, author): bit i = story i of today's sequence seenA few bytes per authorYesHandles out-of-order viewing; needs a stable per-author story index
Bloom filterPer viewer, a bit array with k hash functions over seen story IDs~10 bits per item for 1% false positives (β‰ˆ 50 B for 40 items)No β€” false positivesCan claim "seen" for an unseen story; never the reverse

6.1 Why the watermark works so well

When you tap an author, stories play chronologically, and authors only ever append new stories. So "seen" for an author collapses to one number: the highest story ID you've reached. Unseen check: author.latest_story_id > watermark. Resume point: first story with id > watermark. One small hash field per (viewer, author), updated with a "max" operation that's naturally idempotent and order-insensitive:

-- Lua in Redis: only move the watermark forward
local cur = redis.call('HGET', KEYS[1], ARGV[1])
if (not cur) or tonumber(ARGV[2]) > tonumber(cur) then
  redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
end
redis.call('EXPIRE', KEYS[1], 86400)

Trade-off: if a viewer skips ahead, skipped stories count as seen β€” usually acceptable product behaviour. Story IDs must be time-sortable for this to work.

Memory check: 500M viewers Γ— ~50 authors watched Γ— ~20 B (field + value + overhead) β‰ˆ 500 GB β€” a modest sharded Redis cluster, discarded daily.

6.2 When a Bloom filter makes sense

A Bloom filter answers "definitely not seen" or "probably seen" in a tiny fixed space. With n items and m bits, the optimal false-positive rate is about (1 βˆ’ eβˆ’kn/m)k; ~9.6 bits per item with 7 hash functions gives ~1%. A 1% error that marks an unseen story as seen means an author silently loses some views β€” bad for the main tray ring. Bloom filters fit better for "don't show me things I've probably seen" in ranking surfaces (e.g. suggested stories/Reels), where an occasional miss is harmless.

6.3 Reducing write load

7. Viewer lists and celebrity hot partitions

Authors can see who viewed each story. For most users that's a few dozen to a few thousand rows. For a celebrity with hundreds of millions of followers, one story may collect tens of millions of views within hours.

7.1 The hot-partition problem

If story_views is partitioned by story_id alone, all 50M view writes for one story hit one partition β€” owned by one small replica set in Cassandra β€” while the rest of the cluster idles. That node's write path, compaction and disk all saturate (a hot partition), and the partition grows to gigabytes, making reads and repairs painful. See Sharding.

 story_id only:                       (story_id, bucket = hash(viewer_id) % 64):
  node A  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ 50M     node A β–ˆβ–ˆβ–ˆ  node B β–ˆβ–ˆβ–ˆ  node C β–ˆβ–ˆβ–ˆ  node D β–ˆβ–ˆβ–ˆ ...
  node B  ▏                            (~780k rows per bucket, spread across the ring)
  node C  ▏

7.2 Mitigations

8. Replies and reactions

Replying to a story or tapping a quick-reaction emoji doesn't create a comment; it sends a direct message to the author that references the story:

Message { thread_id, sender_id, type: "story_reply", text: "πŸ˜‚ so good",
          story_ref: { author_id: 7, story_id: 31415..., media_id: 991 } }

9. Highlights and the archive

Highlights are named collections of past stories pinned to the profile, exempt from the 24-hour TTL. Instagram also keeps a private Stories Archive (on by default) so authors can revisit and re-share their old stories. So "expiry" really means public visibility expiry, not necessarily data deletion.

10. Prefetching for instant tap-through

Users tap through stories every 1–5 seconds. If each tap waited for a CDN fetch (50–300 ms on mobile, worse on 3G), the experience would stutter. The app therefore prefetches:

  1. When the tray loads, the response already contains signed CDN URLs for each tray author's first unseen story; the app downloads the first 3–5 authors' first item in the background.
  2. While you watch story k, the app fetches k+1 and k+2, plus the next author's first unseen story (because swiping to the next author is common).
  3. For video, it prefetches only the first segment(s) (a second or two) of an adaptive stream, not the whole file.
  4. Prefetching adapts to conditions: more on Wi-Fi, less on cellular or in data-saver mode, none on low battery; prefetches are cancelled when the user leaves.

Cost estimate: prefetching 5 photos at ~150 KB = 750 KB per tray open; at 5B tray opens/day that's ~3.75 PB/day of potentially wasted CDN egress if nobody tapped β€” so prefetch depth is tuned using measured tap-through probabilities. See Clients and CDN.

11. Privacy filtering

Each story has an audience rule; enforcement must be correct even as relationships change after posting.

RuleStored asCheck
Private accountUser setting + approved followers in the graphViewer must be an approved follower
Close friends storyaudience = CLOSE_FRIENDS on the story; close_friends(owner, friend)Viewer ∈ author's close friends list now
Hide story fromstory_hidden_from(owner, viewer)Viewer not in the hidden set
BlockingBlock edges in the social graphNeither has blocked the other
MutingMute edgeHide from viewer's tray (but still viewable on profile)

12. How Instagram uses all this

πŸ“± Post storypre-signed upload
β†’
Stories servicerow + archive + outbox
β†’StoryCreated
Kafka
β†’
Fan-out + media workerstray inboxes, renditions
β†’
Redis / Cassandra / S3
πŸ“± Open app
β†’
Tray buildermerge, filter, rank
β†’signed URLs
CDNprefetch next stories
β†’view events
Kafka β†’ watermarks, viewer lists, counts
πŸ“Έ In Instagram In the architecture diagram, the Stories Service (24h TTL) stores story metadata in the Cassandra-style metadata store (partitioned by author), media in Object Storage behind the CDN, tray inboxes and seen-state in the Redis cache tier, and publishes StoryCreated to Kafka for tray fan-out, media processing, notifications ("X added to their story" for close relationships) and analytics. Publicly, Instagram has said Stories reached around 500M daily users; with assumptions like those in section 2, that's hundreds of millions of new stories and tens of billions of views per day β€” which is why view recording, not story creation, dominates the design. Instagram is also known to use Cassandra heavily (its engineers have spoken publicly about running it at large scale, including the Rocksandra storage-engine work), a natural fit for append-heavy, TTL-friendly data like stories and views.

13. Trade-offs & common pitfalls

βœ… What this design gets right

  • Exact 24 h visibility via query-time filtering, independent of cleanup lag
  • Cheap tray reads via push fan-out, bounded by a celebrity pull path
  • Compact, idempotent seen-state with per-author watermarks
  • Hot stories spread over many partitions; counts maintained incrementally
  • Instant UX through prefetching

⚠️ Costs and compromises

  • Several expiry mechanisms to operate and keep consistent
  • Fan-out write amplification for ordinary authors with many followers
  • Watermarks treat skipped stories as seen
  • Viewer counts are eventually consistent (seconds behind)
  • Prefetching wastes bandwidth on stories never watched

Common pitfalls

14. Real-world technologies

TechnologyRole in a stories system
Apache Cassandra / ScyllaDB (TTL, TimeWindowCompactionStrategy)Story metadata and view records with native TTL and efficient expiry of time-bucketed data
Redis (sorted sets, hashes, EXPIRE, HyperLogLog, bitmaps)Tray inboxes, seen watermarks, approximate unique view counts, bitmaps
DynamoDB TTLManaged alternative: items deleted asynchronously after expiry (so still filter at query time)
Amazon S3 lifecycle rules / GCS Object LifecycleReclaiming ephemeral media in bulk by prefix or tag
Apache Kafka + Flink/Spark StreamingView-event pipeline; batching viewer-list writes; aggregating counts
Delayed queues (SQS delay, Redis ZSET schedulers)Triggering the sweeper's "story expired" side effects
CDN + HLS/DASHServing story photos and video segments; prefetching first segments

15. Interview corner

How would you implement the 24-hour expiry of stories?

In layers. Correctness: every read path filters on the story's server-assigned timestamp (embedded in its time-sortable ID): id > id_at(now βˆ’ 24h), and media URLs are signed with short expiry. Reclamation: store active stories in a Cassandra table with USING TTL 86400 and time-window compaction; Redis tray entries and seen-state with EXPIRE; a sweeper driven by a delayed queue purges caches and emits StoryExpired; S3 lifecycle rules delete ephemeral media under a dedicated prefix after a couple of days. Archived/highlighted stories live in non-TTL tables and permanent storage. TTL alone isn't enough because tombstones/compaction and lifecycle processing are asynchronous.

How do you build the stories tray quickly for a user who follows 1,000 accounts?

Hybrid fan-out. On StoryCreated, workers add author β†’ latest_ts to each follower's Redis sorted-set tray inbox (skipping blocked/hidden/non-close-friends). Celebrities above a threshold aren't fanned out; their active stories are cached globally and merged at read time for the few celebrities the viewer follows. Tray read: ZRANGEBYSCORE over the last 24 h (typically 30–100 authors), merge celebrities, re-check privacy, look up seen watermarks, rank by an affinity model with unseen first. One sorted-set read plus small multi-gets, well under 100 ms.

How do you store which stories each user has seen, for 500M daily viewers?

Per (viewer, author) watermark = highest story ID seen, in a Redis hash per viewer with 24 h expiry. Because an author's stories play in order and new ones only append, unseen = latest_story_id > watermark, and playback resumes at the first ID above it. Updates use a "max" so they're idempotent and order-insensitive. Roughly 50 authors Γ— ~20 B per viewer β‰ˆ 1 KB β†’ ~500 GB total, discarded daily. The client batches mark-seen calls; a Kafka pipeline applies them. A Bloom filter would be smaller but its false positives would hide unseen stories, so I'd reserve it for ranking surfaces.

A celebrity's story gets 40M views in an hour. What breaks and how do you fix it?

If views are partitioned by story_id, one Cassandra partition (one replica set) takes ~11k writes/s and grows to gigabytes β€” a hot partition. Fix: bucket the key (story_id, hash(viewer) % N) with N chosen per author size; route view events through Kafka and batch-write; maintain the view count incrementally in the stream (or HyperLogLog for uniques) instead of counting rows; page the viewer list for the author and show a ranked subset; TTL the views after ~48 h. Reads of that story are served from CDN/cache, so the read side is fine.

How do highlights coexist with a 24-hour TTL?

Visibility expiry β‰  deletion. Stories are written to an archive table without TTL (if archiving is on); highlights reference archived story IDs. Media for archived/highlighted stories must not be removed by lifecycle rules: either store it in a permanent prefix from the start when archiving is enabled, copy it on "add to highlight", or reference-count blobs and only delete at zero. Highlight reads apply account privacy and blocks like any profile content.

A user removes a friend from Close Friends after posting a close-friends story. Can the friend still see it?

They must not. Fan-out already pushed the author into the friend's tray, so correctness requires a read-time check: when building the tray and when fetching the story list or media URLs, the service verifies the viewer is currently on the close-friends list. Stale tray entries are filtered out (and optionally removed asynchronously). Signed media URLs expire quickly, limiting replay of a previously fetched URL.

How do you make tapping through stories feel instant?

Prefetch. The tray response includes signed CDN URLs of each author's first unseen item; the app preloads the first few authors' first items, then while viewing item k preloads k+1, k+2 and the next author's first item. For video it preloads only the first segment of the ABR stream. Depth adapts to network, battery and data-saver settings and is tuned on tap-through probabilities to limit wasted egress. Popular stories are hot in CDN edges, so prefetch requests are cheap cache hits.

16. Check your understanding

Cassandra hides expired cells on read. Why is its TTL still not enough to guarantee a story disappears everywhere at exactly 24 hours?

The Cassandra row does disappear on time, but derived copies (Redis caches, tray entries, CDN URLs, other tables) don't share that TTL, and storage reclamation (tombstones, compaction) and object lifecycle rules are asynchronous. A query-time filter on every read path plus short-lived signed URLs give one consistent, exact rule everywhere.

With 500M viewers each watching 40 stories/day, what is the average view-write rate, and how does client batching of 10 views per request change the request rate?

20B views/day Γ· 86,400 β‰ˆ 231k views/s. Batching 10 views per call cuts it to β‰ˆ 23k requests/s at the API (the number of view records stays the same, but they're written in bulk via the stream).

A viewer's watermark for author A is story 105. A has stories 101, 104, 105, 108, 110. What does the ring show, and where does playback start?

Latest story 110 > 105, so the ring shows unseen. Playback starts at the first story with ID > 105: 108.

Why is a Bloom filter risky for the tray's seen/unseen ring?

Bloom filters have false positives: they may say "seen" for a story never viewed. The ring would turn grey and the viewer might skip the author β€” the author silently loses views. They have no false negatives, so they suit "avoid re-showing" use cases, not exact UI state.

Scenario: you add a lifecycle rule deleting everything under stories/ after 2 days. Users complain highlights are broken. Why, and what's the fix?

Highlighted (and archived) stories share the same prefix, so their media was deleted. Separate ephemeral from retained media (different prefixes or object tags), copy on "add to highlight", or reference-count blobs and delete via a sweeper only when unreferenced.

Why does the tray use push fan-out for most authors but pull for celebrities?

Push makes reads cheap (one sorted-set read) at the cost of one write per follower per story. For an author with 300M followers that's 300M writes per story β€” too much amplification and delay. Pulling those few celebrity authors at read time (from a hot cache) bounds write cost, while the number of celebrities any viewer follows is small enough to merge cheaply.

How many partitions would a 50M-view story's viewer list be spread across with 64 buckets, and how many rows per partition?

64 partitions, β‰ˆ 50M / 64 β‰ˆ 780k rows each β€” spread across many nodes instead of one 50M-row partition on a single replica set. Huge accounts may use more buckets.

🎯 Key takeaways

Related lessons