- Stories are photos/videos visible for 24 hours, shown in a ranked tray at the top of the app, with seen/unseen rings, viewer lists, replies/reactions, highlights and close friends audiences.
- Expiry is enforced in layers: an exact query-time filter on
created_atfor correctness, plus Cassandra TTL / Redis EXPIRE, a background sweeper and object-storage lifecycle rules to reclaim space. - The tray is built like a mini feed: fan-out on write of "author X has a new story" into followers' tray sets (pull for celebrities), then ranked by affinity, unseen first.
- Views are the dominant write load (tens of billions/day). Track seen-state compactly (per-author watermarks, bitmaps, Bloom filters) and protect celebrity stories from hot partitions by bucketing and batching viewer-list writes.
- "Instant" tap-through comes from prefetching the next stories' media; privacy (close friends, hidden-from, blocks) is checked at fan-out and at read time.
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:
- Everything is hot, then nothing is. A story gets almost all of its views in its first few hours and none after 24 hours. Feed posts have a long tail; stories have a cliff.
- Reads create writes. Every view is recorded (seen-state for the viewer, viewer list for the author). Stories are therefore write-heavy in a way feed browsing isn't.
- Expiry must be correct. If a story is still visible at hour 25, that's a privacy bug, not a performance issue.
- The UI demands instant tap-through. Users flick through dozens of stories in a minute; any spinner kills the experience.
- Audiences vary per story: all followers, close friends only, minus people the author hid it from.
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
- Low latency: tray loads in <200 ms server time; next story starts in <100 ms (needs prefetch).
- High availability for viewing; brief staleness in viewer counts is fine (eventual consistency).
- Strict expiry and audience enforcement β correctness, not best effort.
- Handle extreme skew: a celebrity's story may get tens of millions of views in an hour.
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
- Partition by author: "all active stories by author X" β the main read when you tap a tray item β is one partition scan, newest-first. This mirrors the post metadata store (Cassandra, sharded by user_id) in the diagram.
- Story IDs are time-sortable (Snowflake-style), so ordering and "newer than my watermark" comparisons need no extra timestamp index.
- Views are keyed by story, not by author, so the author can page through viewers of one story; the
bucketspreads a hot story across partitions (section 7). - Media lives in object storage, uploaded through the same pre-signed flow as posts (Media upload), and is served via the CDN.
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.
| Mechanism | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Cassandra TTL | INSERT β¦ 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 EXPIRE | SET 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 filtering | Every 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 sweeper | A 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 rules | Bucket 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:
- 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.
- Storage reclamation comes from Cassandra TTL (in a table dedicated to TTL'd data, with time-window compaction) and Redis EXPIRE.
- 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]). - Media is reclaimed by lifecycle rules on an ephemeral prefix β only for stories not kept in the archive or a highlight (section 9).
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
| Approach | How | Cost |
|---|---|---|
| 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. |
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:
- Relationship strength: DMs exchanged, likes/comments between the two, profile visits, being on each other's close friends list, mutual follows.
- Story history: how often the viewer watched this author's past stories to the end vs skipped.
- Freshness: how recently the author posted; number of unseen items.
- Content type: live videos are usually pinned near the front.
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:
| Design | Stores | Size per viewer/day | Exact? | Notes |
|---|---|---|---|---|
| Per-viewer set of story IDs | seen:{viewer} = {story_id, β¦} | ~40 Γ 8 B β 320 B (+ overhead) | Yes | Simple; 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 each | Yes, for in-order viewing | Works because an author's stories are played oldestβnewest and new ones only append |
| Bitmaps | Per (viewer, author): bit i = story i of today's sequence seen | A few bytes per author | Yes | Handles out-of-order viewing; needs a stable per-author story index |
| Bloom filter | Per 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 positives | Can 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
- Client batching: the app marks stories seen locally (instant UI) and sends batched
mark_seencalls every few seconds or when leaving the viewer β one request for 10 stories instead of 10. - Async pipeline: the API appends view events to Kafka; consumers update watermarks, viewer lists and counts in bulk.
- Multi-device: watermarks are server-side, so seen-state syncs across phone and web.
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
- Bucketed partition key:
(story_id, bucket)withbucket = hash(viewer_id) % N. N can be 1 for ordinary users and larger (64β1024) for big accounts, chosen at story creation from the author's follower count. Reading the full list means querying N partitions in parallel β fine, because only the author reads it. - Buffer and batch: view events flow through Kafka (keyed by story_id+bucket); consumers write in batches of hundreds of rows, smoothing spikes.
- Counts separately: never
COUNT(*)the view list. Maintain a counter aggregated in the stream (increment by batch size), or a HyperLogLog for approximate unique viewers (~12 KB per story, ~0.8% error) β see counters at scale. - Show a slice: the UI pages through viewers (e.g. 50 at a time) and for huge audiences shows a ranked subset (people you interact with first) plus the total.
- Short retention: viewer lists are kept only for a limited window after posting (Instagram shows viewers for up to about 48 hours), so a TTL bounds total storage.
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 } }
- The reply goes through the Direct Messaging service (WebSocket delivery, Messages store) and triggers a notification.
- The DM stores a reference, not a copy. When the story expires (or the viewer loses access), the thread shows "story unavailable" β permission is re-checked when rendering.
- Interactive stickers (polls, quizzes, question boxes, emoji sliders) write responses to a per-story table and maintain aggregated tallies, using the same counter techniques as likes.
- Rate limits and spam filters apply as for DMs: replies are a common spam vector.
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.
- Data: on creation the story is written to both the TTL'd
storiestable and the non-TTLstory_archive(if archiving is on). A highlight stores an ordered list of archived story IDs plus a cover image. - Media lifecycle: blobs must not be deleted by a blanket lifecycle rule if they're archived or highlighted. Options: (a) write story media to a permanent prefix when archiving is on, and to an ephemeral prefix (with a lifecycle rule) only when it's off; (b) on "add to highlight", copy the object into a permanent prefix; (c) reference-count blobs and let a sweeper delete only when the count reaches zero.
- Visibility: highlights follow the account's privacy (private account β approved followers only), and the read path still applies blocks.
- Viewer lists for highlight views are typically not retained like live story views β another reason the views table has its own TTL.
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:
- 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.
- 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).
- For video, it prefetches only the first segment(s) (a second or two) of an adaptive stream, not the whole file.
- 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.
| Rule | Stored as | Check |
|---|---|---|
| Private account | User setting + approved followers in the graph | Viewer must be an approved follower |
| Close friends story | audience = CLOSE_FRIENDS on the story; close_friends(owner, friend) | Viewer β author's close friends list now |
| Hide story from | story_hidden_from(owner, viewer) | Viewer not in the hidden set |
| Blocking | Block edges in the social graph | Neither has blocked the other |
| Muting | Mute edge | Hide from viewer's tray (but still viewable on profile) |
- At fan-out time: don't push a close-friends story's author into the trays of non-close-friends; skip blocked/hidden users. This keeps trays clean and saves writes.
- At read time: re-check, because lists change after fan-out (you remove someone from close friends, block them, or they unfollow). The authoritative check runs when the tray is built and when the story list/media URLs are requested.
- Media URLs are signed and short-lived, so a URL captured before you were blocked stops working quickly.
- The close-friends list is private to its owner β the Graph service never exposes "who has me on close friends".
12. How Instagram uses all this
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.- Expiry: read paths filter on the story ID's embedded timestamp; TTLs and sweepers clean up; the archive and highlights retain what the author keeps.
- Celebrities: stories of very large accounts are pulled at tray-build time rather than fanned out, and their view writes are bucketed and batched.
- Experience: aggressive, adaptive prefetching makes tapping feel instant; seen-state syncs across devices via server-side watermarks.
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
- Relying only on TTL/lifecycle for visibility β stories visible past 24 h because compaction or lifecycle processing lags.
- Lifecycle rules deleting highlighted media β broken highlights. Keep ephemeral and permanent media separate or reference-count.
- Partitioning views by story_id alone β hot partitions for celebrity stories.
COUNT(*)for view counts β scanning millions of rows per refresh.- Checking privacy only at fan-out β someone removed from close friends still sees the story already in their tray.
- Using client time for expiry β users change their phone clock to see or hide stories.
- Mixing TTL'd and permanent rows in one Cassandra table β tombstone build-up and poor compaction.
14. Real-world technologies
| Technology | Role 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 TTL | Managed alternative: items deleted asynchronously after expiry (so still filter at query time) |
| Amazon S3 lifecycle rules / GCS Object Lifecycle | Reclaiming ephemeral media in bulk by prefix or tag |
| Apache Kafka + Flink/Spark Streaming | View-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/DASH | Serving 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.
- Stories are hot then dead and write-heavy on read: views outnumber new stories ~50:1, and each view is a write.
- Enforce expiry in layers: exact query-time filtering for visibility; Cassandra TTL, Redis EXPIRE, sweepers and lifecycle rules for cleanup.
- Build the tray with hybrid fan-out (push for most, pull for celebrities), then rank unseen first by affinity.
- Track seen-state with per-author watermarks; avoid hot partitions with bucketed keys, stream batching and incremental counts.
- Highlights and the archive make "expiry" about visibility, so media lifecycle must respect retained stories.
- Check privacy (close friends, hidden, blocks) at fan-out and read time, and prefetch for instant tap-through.
Related lessons
- News Feed β the same fan-out-on-write vs read trade-off, in full
- Media Upload β how story media is uploaded
- Caching β Redis data structures, EXPIRE and hot keys
- Databases β why wide-column stores suit TTL'd, append-heavy data
- Sharding β hot partitions and bucketed keys
- Likes & Counters β counting views and reactions at scale
- Social Graph β followers, close friends and blocks used for audience checks
- Direct Messaging β where story replies go
- Object Storage β lifecycle rules for media