Designing an Ephemeral “Stories” System That Expires in 24 Hours

Designing an Ephemeral 'Stories' System That Expires in 24 Hours

Designing an Ephemeral Stories System That Expires in 24 Hours

A full walkthrough of how to build ephemeral, self-expiring content at massive scale — where the hardest problem isn’t showing content, it’s guaranteeing it reliably disappears on schedule.

01

Introduction & History

Most systems in software engineering are built to preserve data forever, or at least until someone explicitly decides to delete it. “Stories” — the now-familiar format of photos and short videos that appear at the top of a social app and vanish exactly 24 hours after posting — flip that assumption on its head. This is a system deliberately engineered to forget, on a precise schedule, at massive scale, and getting that forgetting right turns out to be a genuinely interesting distributed systems problem.

The format traces back to a single product launch in 2013, when a messaging app introduced a feature that let people share a photo or short video that any of their friends could view, but which disappeared permanently after 24 hours. The idea was a deliberate departure from the permanent-record model that had defined social media up to that point — a lower-pressure way to share a passing moment without it becoming part of a permanent, searchable history. It proved popular enough that within a few years, most major social platforms had shipped their own version of the same format, adopting the same core mechanic: post something, it’s visible for a day, then it’s gone.

From an engineering standpoint, this format asks for a genuinely different set of guarantees than a typical content-sharing feature. Every other system in this tutorial series has assumed content should persist indefinitely and treated deletion as a rare, exceptional operation. Here, deletion isn’t exceptional — it’s the default, scheduled, guaranteed outcome for every single piece of content the system stores, and it needs to happen reliably, precisely, and at a scale of millions of pieces of content expiring continuously, all day, every day.

💬
What an interviewer may ask

“What’s fundamentally different about designing for content that’s supposed to disappear, compared to a system built to keep content forever?” The core shift is that deletion becomes a first-class, guaranteed system behavior rather than a rare edge case — every architectural decision, from storage choice to caching strategy, needs to actively support “this will definitely stop existing at a specific time” rather than the more usual “this should persist reliably.” Getting deletion wrong here isn’t just an inconvenience; it directly breaks the product’s core privacy promise to users, which is a genuinely different kind of correctness requirement than most systems are designed around.

02

Problem & Motivation

Problem statement

Design a system to support “stories”-style ephemeral content that automatically expires 24 hours after posting, viewed by millions of users per day. Users should see an aggregated view of which of the people they follow currently have active (non-expired) stories, be able to view those stories in sequence, and expired content must reliably stop being accessible to anyone once its 24-hour window ends.

2.1 Sub-problem 1 — guaranteeing precise, reliable expiration at scale

The most distinctive challenge here is making sure content actually disappears when it’s supposed to — not “eventually, whenever a cleanup job gets around to it,” but within a tight, predictable window of the actual 24-hour mark. At the scale of millions of pieces of content being created continuously throughout the day, a naive approach — a periodic batch job that scans everything and deletes what’s expired — either runs too infrequently (leaving expired content visible for an unacceptably long window) or too frequently (creating a constant, expensive scanning burden that doesn’t actually scale well as content volume grows).

2.2 Sub-problem 2 — an entirely different caching philosophy than a typical media system

The previous tutorial in this series built an entire architecture around the idea that image content is immutable and can be cached aggressively, indefinitely, since it never needs to become unavailable. Here, that assumption is inverted: every piece of cached content has a hard expiration, and caching layers — including any CDN involved — need to respect that expiration precisely, rather than treating long cache lifetimes as an unambiguous good, the way the previous tutorial did.

2.3 Sub-problem 3 — the “story ring” is its own lightweight fan-out problem

Before a viewer can watch anyone’s story, they need to see which of the people they follow currently have an active story at all — typically shown as a row of avatars at the top of the app. This is a distinct, smaller-scale version of the fan-out problem covered in an earlier tutorial in this series: whenever someone posts a new story, everyone who follows them needs to eventually see an indicator that a new story is available, and that indicator itself needs to stop showing once the story expires.

2.4 Sub-problem 4 — viewer tracking with a hard time boundary

Just like the profile-view tracking problem covered elsewhere in this series, story creators typically want to know who has viewed their story. But here, the entire viewer history for a given story is itself bounded by the same 24-hour window — once a story expires, its view history becomes irrelevant (and, depending on the platform’s privacy stance, may need to be deleted along with the content itself), which is a meaningfully different retention requirement than the indefinitely-retained view history covered in that earlier system.

💬
What an interviewer may ask

“Of these four sub-problems, which would you consider the true core challenge that makes this system different from anything else in a typical social platform?” Reliable, precise, scheduled expiration is the genuinely novel core challenge — the fan-out and viewer-tracking sub-problems are variations on patterns that show up in other systems throughout a social platform, just adapted to a shorter time horizon. Guaranteeing that a specific piece of content actually stops being accessible at a specific, predictable moment, across every layer of the system — storage, cache, CDN — simultaneously and reliably, at a scale of millions of expiring items, is the problem that doesn’t have a close analog elsewhere in a typical social platform’s architecture.

03

Requirements & Capacity Estimation

3.1 Functional requirements

  • Users can post a photo or short video as a story, visible to their followers (or a restricted subset, like a “close friends” list).
  • A story automatically becomes inaccessible exactly 24 hours after posting, with no manual action required.
  • Users see an aggregated “story ring” showing which followed accounts currently have at least one active story.
  • Multiple stories from the same user within the window are viewed as an ordered sequence.
  • Story creators can see who has viewed their story, for as long as that story remains active.

3.2 Non-functional requirements

  • Guaranteed, timely expiration — the headline requirement: expired content must become inaccessible within a tight, well-defined window of its actual expiry time, not “eventually.”
  • High read throughput — story viewing, like most social content, is read-dominated, and needs to remain fast even as content volume and viewer count scale.
  • Bounded storage growth — unlike a permanent content platform, total storage should stay roughly proportional to one day’s worth of content, not grow unboundedly over time, since content is continuously expiring as new content arrives.
  • High availability — consistent with the AP-leaning stance used throughout this tutorial series, brief staleness (a story ring that updates a few seconds late) is preferable to the whole system becoming unavailable.
  • Privacy-respecting deletion — expired content, and ideally its associated viewer history, should be genuinely removed, not merely hidden from the UI while still technically retrievable.

3.3 Back-of-the-envelope capacity estimation

Let’s work through realistic numbers for a platform with 500 million daily active users, broadly consistent with the scale of the largest real-world platforms offering this feature.

500M
Daily active users
100M
Stories created per day
~40
Avg distinct viewers / story
~2 MB
Avg story media size

3.4 The storage growth curve looks fundamentally different here

MetricCalculationResult
New storage added per day100M × 2 MB≈ 200 TB/day
Storage expiring per day (steady state)≈ same as new, one day later≈ 200 TB/day
Steady-state total active storage≈ one day’s worth of content≈ 200 TB (roughly constant)

This is worth sitting with, because it’s a genuinely different shape than every other system covered in this series. The photo-sharing platform’s storage grew by tens of petabytes every year, accumulating indefinitely. Here, because content is constantly expiring at roughly the same rate new content arrives, total active storage stays roughly flat over time, regardless of how long the platform has existed — a direct, structural consequence of the 24-hour expiration requirement, and a meaningfully different cost and capacity-planning picture from a system built around permanent content.

3.5 Read traffic estimation

MetricCalculationResult
Total story views per day100M stories × 40 avg viewers≈ 4 billion views/day
Average views per second4,000,000,000 ÷ 86,400≈ 46,300 / sec
Peak views per second (3× average)≈ 139,000 / sec
Story-ring fetches per day (feed opens)500M DAU × 10 opens≈ 5 billion/day

The read-to-write ratio is once again heavily skewed toward reads — consistent with most of the systems in this tutorial series, though the exact multiplier differs from any of them. This confirms that the same broad instinct applies here as elsewhere: optimize aggressively for fast, cheap reads. But the capacity picture is genuinely distinctive in one specific way worth restating: this is very likely the only system in this tutorial series where the relevant capacity question isn’t “how do we handle unbounded growth” but “how do we handle a roughly constant, self-limiting steady state” — which changes the capacity planning conversation considerably.

💬
What an interviewer may ask

“Given that total storage stays roughly flat rather than growing indefinitely, does that change how you’d think about storage tiering compared to a permanent-content platform?” Significantly — the elaborate hot/warm/cold tiering strategy that mattered enormously for a permanent photo-sharing platform is largely unnecessary here, since nothing survives long enough to justify migrating it to a cheaper, slower tier; content is either actively within its 24-hour window (and needs to stay quickly accessible) or it’s expired and should be gone entirely. This is a good example of how a seemingly generic best practice — “tier your storage by access recency” — doesn’t apply universally, and recognizing when it genuinely doesn’t apply is just as valuable as knowing how to apply it when it does.

04

High-Level Architecture & Components

graph TB Client[“Client Apps”] Gateway[“API Gateway”] StorySvc[“Story Service”] TTLStore[(“TTL-Native Store
Cassandra / DynamoDB / Redis”)] BlobStore[(“Media Blob Storage
with lifecycle policy”)] CDN[“CDN – bounded-TTL cache”] RingSvc[“Story Ring Service”] RingIndex[(“Active-Story Index
self-expiring”)] ViewSvc[“Viewer Tracking Service”] ViewStore[(“Viewer Store
TTL-bound”)] Queue[“Event Queue”] Client –>|”POST /stories”| Gateway Gateway –> StorySvc StorySvc –> BlobStore StorySvc –> TTLStore StorySvc –>|”publish event”| Queue Queue –> RingSvc RingSvc –> RingIndex StorySvc –> CDN BlobStore –> CDN Client –>|”GET /story-ring”| RingSvc Client –>|”GET /stories/id”| StorySvc Client –>|”view event”| ViewSvc ViewSvc –> ViewStore CDN –>|”serve, respecting TTL”| Client
Diagram 1 — High-level architecture: every storage layer is TTL-aware by design, not just the primary content store.

The defining feature of this architecture, worth stating explicitly before walking through the components, is that expiration is handled natively at nearly every layer, rather than being bolted on as a separate cleanup process. This is a deliberate design principle this tutorial returns to repeatedly: whenever a storage or caching system offers native, built-in support for automatic expiration, using it directly is almost always preferable to building custom deletion logic on top of a system that wasn’t designed for it.

4.1 Story Service

Handles story creation: validating uploaded media, storing it, recording metadata with an explicit expiration timestamp, and publishing an event so other parts of the system (notably the Story Ring Service) can react to the new content.

4.2 TTL-Native Store

The metadata store for story records, chosen specifically because it offers built-in, automatic expiration of records after a specified duration — covered in real depth in the next section, since this is the single most important infrastructure decision in the whole design.

4.3 Media Blob Storage

Stores the actual photo or video bytes, configured with an object lifecycle policy that automatically deletes objects after a set duration slightly longer than the content’s logical 24-hour window, providing a backstop cleanup mechanism independent of the metadata store’s own expiration.

4.4 Story Ring Service & Active-Story Index

Maintains, for each user, a lightweight, self-expiring index of which followed accounts currently have at least one active story — the mechanism behind the “story ring” UI, covered in its own dedicated section shortly.

4.5 Viewer Tracking Service & Viewer Store

Tracks who has viewed a given story, using the same TTL-bound principle as the primary content store, since viewer history for an expired story is no longer meaningful and, for privacy reasons, generally shouldn’t persist past the content’s own lifetime.

4.6 CDN with bounded TTL

Unlike the indefinite, aggressive caching used for permanent photo content elsewhere in this tutorial series, the CDN layer here must be configured with cache lifetimes that respect the content’s own 24-hour expiration — a story’s cached bytes should become unavailable at or before the same moment the content officially expires, never meaningfully after.

💬
What an interviewer may ask

“Why have both a TTL on the metadata store and a separate lifecycle policy on blob storage, rather than relying on just one?” Defense in depth for a requirement where getting it wrong has real privacy consequences. The metadata store’s TTL is what makes a story stop appearing in the product almost immediately after expiry — but if the underlying media bytes somehow remained in blob storage indefinitely due to a bug or an edge case, a sufficiently motivated party might still be able to access supposedly-deleted content through a leaked or cached direct link. A second, independent expiration mechanism at the blob storage layer closes that gap, ensuring the actual bytes are also reliably removed, not just hidden from the normal product surface.

05

TTL-Based Storage & Expiry — The Core of This Design

This section addresses the headline challenge directly: how do you make sure millions of pieces of content actually disappear, reliably, close to their exact expiration time, without building an expensive, error-prone custom cleanup system?

5.1 The anti-pattern — a periodic sweep job

The naive approach is a scheduled batch job that periodically scans all stories, checks each one’s age, and deletes anything past 24 hours. This has two real problems. First, precision is tied directly to how often the job runs — a job that runs every hour means expired content can remain visible for up to an hour past its true expiration, which is a meaningfully long, user-visible violation of the product’s core privacy promise. Running the job more frequently to tighten that window means repeatedly scanning a large, continuously growing dataset, which becomes an increasingly expensive, poorly-scaling operation exactly as content volume grows — the worst possible direction for a cost to scale in.

5.2 The right approach — native TTL support in the storage layer itself

Several widely-used distributed storage systems support automatic, per-record expiration as a first-class, built-in feature, and using this directly is dramatically more efficient than any custom sweep-based alternative.

SystemTTL mechanismHow expiry actually happens
RedisEXPIRE / SET ... EXCombination of lazy expiry (checked on access) and an active background cycle that samples and removes expired keys continuously, without a full scan
Cassandra / ScyllaDBPer-column or per-row TTLExpired data is marked with a tombstone and physically removed during normal compaction, without a separate scan or job
DynamoDBTTL attribute on an itemA background process continuously scans for and removes expired items, decoupled from and not counted against normal read/write capacity

The common thread across all three: expiration is handled by the storage engine’s own internal machinery, which is built specifically to do this efficiently at scale, rather than by application-level code that has to rediscover and reimplement the same problem. This is a genuinely important, broadly-applicable engineering principle worth internalizing well beyond this one system: when a storage system already offers a native mechanism for a problem you’re facing, use it, rather than building custom logic on top of a system that wasn’t designed to support that behavior efficiently.

StoryRepository.java — native TTL on writesjava
// Storing a story with native TTL, using a Redis-style client
public class StoryRepository {

    private final RedisClient redis;
    private static final Duration STORY_TTL = Duration.ofHours(24);

    public void saveStory(Story story) {
        String key = "story:" + story.getStoryId();
        String value = serialize(story);
        // TTL is set directly on the write itself - no separate cleanup job.
        redis.set(key, value, STORY_TTL);
    }

    public Optional<Story> getStory(String storyId) {
        String value = redis.get("story:" + storyId);
        // If the key has expired, Redis has already removed it -
        // this simply returns empty, no extra check needed on the read side.
        return value == null ? Optional.empty() : Optional.of(deserialize(value));
    }
}

// The equivalent idea using a Cassandra-style store, where TTL
// is set per-write using CQL's native TTL clause
public class CassandraStoryRepository {

    private static final int STORY_TTL_SECONDS = 24 * 60 * 60;
    private final CqlSession session;

    public void saveStory(Story story) {
        session.execute(
            "INSERT INTO stories (story_id, owner_id, media_object_id, created_at) " +
            "VALUES (?, ?, ?, ?) USING TTL ?",
            story.getStoryId(), story.getOwnerId(), story.getMediaObjectId(),
            story.getCreatedAt(), STORY_TTL_SECONDS
        );
    }
}

5.3 Why this approach is precise, not just convenient

Beyond avoiding the cost of a custom sweep job, native TTL support is also more precise. Because expiration is tracked per-record at the moment of write, rather than computed later by comparing a stored timestamp against the current time during a periodic scan, the storage engine can make expired data actually inaccessible essentially immediately once its TTL elapses — for Redis specifically, an access to an expired key is checked and rejected right at read time (lazy expiry), even before the background cleanup cycle has gotten around to physically removing it, so there’s no window where a client-facing read can return content past its intended lifetime.

5.4 Where the buffer between logical and physical deletion matters

It’s worth being precise about a subtlety here: “logically expired” (no client can retrieve it anymore) and “physically deleted” (the bytes are fully removed from disk) aren’t always the exact same instant, particularly for Cassandra-style tombstone-and-compaction deletion, where the physical removal happens during a later compaction cycle. This is generally an acceptable gap for this product’s purposes — what actually matters for the privacy promise is that no client can retrieve the content anymore, which native TTL support guarantees essentially immediately at the logical level, even if the very last physical bytes linger briefly on disk before compaction clears them.

💬
What an interviewer may ask

“If a storage system’s native TTL granularity is only precise to the nearest minute rather than the exact second, is that acceptable for this product?” Almost certainly yes — a privacy promise of “gone after 24 hours” doesn’t require second-level precision in practice; users aren’t checking their stopwatch against the exact posting timestamp. What actually matters is that the imprecision is small, bounded, and consistent, and clearly communicated as part of the product’s design (typically as “expires 24 hours after posting,” implicitly allowing for this kind of small, standard system-level tolerance) rather than promising a guarantee tighter than the chosen storage system can actually deliver.

06

Story Ring Fanout

Before a viewer can watch anyone’s story, the product needs to show them a lightweight indicator — typically a row of avatars — for which followed accounts currently have at least one active story. This is a smaller, gentler version of the fan-out problem tackled in depth in an earlier tutorial in this series, and it’s worth noticing both how it’s similar and how it’s genuinely different.

6.1 Why this is a much lighter fan-out problem than a content feed

A content feed fans out entire posts — potentially rich, heavy content — to potentially enormous follower counts, which is what made the celebrity-account fan-out problem covered elsewhere in this series so consequential. Here, the fan-out payload is tiny: just an indicator that “this account has an active story,” not the story content itself. Even for an account with an enormous follower count, updating a lightweight “has active story” flag for every follower is a dramatically cheaper operation than fanning out full content — closer in spirit to a presence indicator than to a content distribution problem.

sequenceDiagram participant U as Story Author participant SS as Story Service participant Q as Event Queue participant RS as Ring Service participant RI as Active-Story Index (TTL) participant V as Viewer U->>SS: POST /stories SS->>Q: publish StoryCreatedEvent Q->>RS: consume event RS->>RS: get author’s followers loop for each follower RS->>RI: mark author “has active story” for follower, TTL 24h end V->>RS: GET /story-ring RS->>RI: read active-story flags for followed accounts RI–>>RS: list of accounts with active stories RS–>>V: story ring (avatars, ordered)
Diagram 2 — The story-ring index itself expires on the same schedule as the underlying content, keeping the two naturally in sync.

6.2 Letting the index expire on its own, using the same TTL principle

A particularly elegant aspect of this design: the active-story index entries can carry the exact same 24-hour TTL as the underlying story content itself. This means the “has active story” indicator disappears from a viewer’s story ring automatically, at the same moment the content itself becomes inaccessible, without any separate cleanup logic needing to explicitly reconcile the two — they’re kept in sync simply by using the same expiration mechanism for both, rather than by any custom logic checking one against the other.

StoryRingIndexer.java — batched ring fanoutjava
// Updating the active-story index using the same TTL principle
// as the underlying content - kept in sync "for free"
public class StoryRingIndexer {

    private final RedisClient redis;
    private final FollowGraphClient graphClient;
    private static final Duration RING_TTL = Duration.ofHours(24);

    public void handleStoryCreated(StoryCreatedEvent event) {
        Iterator<String> followerIds = graphClient.getFollowersStream(event.getAuthorId());

        List<String> batch = new ArrayList<>();
        while (followerIds.hasNext()) {
            batch.add(followerIds.next());
            if (batch.size() == 500) {
                markActiveForBatch(batch, event.getAuthorId());
                batch.clear();
            }
        }
        if (!batch.isEmpty()) {
            markActiveForBatch(batch, event.getAuthorId());
        }
    }

    private void markActiveForBatch(List<String> followerIds, String authorId) {
        try (Pipeline pipeline = redis.openPipeline()) {
            for (String followerId : followerIds) {
                String key = "ring:" + followerId;
                // Sorted set per viewer: member = author, score = expiry time.
                pipeline.zadd(key, System.currentTimeMillis() + RING_TTL.toMillis(), authorId);
            }
            pipeline.sync();
        }
    }
}

// Reading the ring - trimming anything whose "expiry score" has passed,
// since a sorted set itself doesn't natively expire individual members
public class StoryRingReader {

    private final RedisClient redis;

    public List<String> getActiveRing(String viewerId) {
        String key = "ring:" + viewerId;
        long now = System.currentTimeMillis();
        redis.zremrangeByScore(key, 0, now);
        return redis.zrevrange(key, 0, -1);
    }
}

Notice the second code sample handles a subtlety worth calling out explicitly: unlike a plain key with an EXPIRE set on it, individual members of a sorted set don’t natively expire on their own in most implementations. The pattern shown — storing an explicit expiry timestamp as the score, and trimming anything past that score on read — approximates the same self-expiring behavior at the individual-member level, which the underlying data structure doesn’t directly provide. This is a good example of adapting the TTL principle established in the previous section to a situation where the most convenient data structure for the access pattern (a sorted set, needed for efficient “get this viewer’s whole ring” queries) doesn’t happen to offer that exact native capability at the granularity needed.

💬
What an interviewer may ask

“What happens to a viewer’s story ring if that viewer follows a celebrity-scale account with millions of followers — does the celebrity problem from feed design reappear here?” To a much smaller degree, and manageable with the same general principle covered for that earlier problem: since the payload here is a tiny flag rather than full content, even a celebrity-scale fan-out of “mark active for N million followers” is considerably cheaper per-follower than a full content fan-out. If it still became a concern at extreme scale, the same threshold-based hybrid approach — pull-based ring computation for only the largest accounts, rather than push-based fan-out — would apply directly, just with a much higher threshold given how much lighter this specific payload is.

07

Viewer Tracking & Seen State

Story creators typically want to know who has viewed their story, and viewers themselves need the app to remember which stories they’ve already seen, so the story ring can visually distinguish “new, unwatched” from “already watched” content. Both of these are variations on the viewer-tracking problem covered in much greater depth in an earlier tutorial in this series, adapted here to the 24-hour time boundary.

7.1 Why exact tracking, not approximate counting, is usually the right choice here

The earlier profile-view tracking system in this series leaned heavily on approximate counting (HyperLogLog) specifically because it needed to track potentially enormous, ever-growing distinct-viewer counts efficiently over long or unbounded time windows. Here, the situation is different in a way that changes the right answer: a given story’s viewer count is bounded both in time (only 24 hours) and, realistically, in scale (a typical story’s audience is the poster’s follower count, rarely approaching the extreme scale that made approximate counting so valuable in that earlier system). For the large majority of stories, an exact, small, capped viewer list is both perfectly affordable and more useful to the product than an approximate count would be, since users specifically want to know who viewed their story, not just an estimated count.

StoryViewTracker.java — TTL-bound exact trackingjava
// Exact viewer tracking, TTL-bound to match the story's own lifetime
public class StoryViewTracker {

    private final RedisClient redis;

    public void recordView(String storyId, String viewerId, Duration remainingTtl) {
        String key = "views:" + storyId;
        // A set naturally deduplicates - a viewer watching the same
        // story twice doesn't inflate the count or list.
        redis.sadd(key, viewerId);
        // Align this set's TTL with the story's own remaining lifetime,
        // so viewer data never outlives the content it describes.
        redis.expire(key, remainingTtl);
    }

    public long getViewCount(String storyId) {
        return redis.scard("views:" + storyId);
    }

    public Set<String> getViewerList(String storyId) {
        return redis.smembers("views:" + storyId);
    }
}

Notice the TTL applied here is deliberately tied to the story’s own remaining lifetime, computed at write time — not a fresh, independent 24-hour TTL — so that viewer data can never outlive the content it describes, even if a view happens to be recorded close to the story’s original expiration moment.

7.2 Handling exceptionally large audiences without abandoning exactness entirely

For the comparatively rare account whose story genuinely does receive an enormous number of views — a large media account or a celebrity-scale profile — a plain, unbounded set could grow large enough to become a genuine memory concern within its 24-hour lifetime. Here, a sensible hybrid mirrors the same eager/lazy thinking used elsewhere in this tutorial series: continue tracking an exact, capped list of the most recent viewers (useful for the product’s “recently viewed by” UI, which realistically only ever displays a handful of names anyway) while switching to an approximate HyperLogLog-based count specifically for the aggregate number once viewer volume crosses a reasonable threshold — the same exact-list-plus-approximate-count pattern used in the profile-view tracking system, just applied here only above a threshold, rather than universally.

7.3 Seen-state tracking, from the viewer’s own perspective

Separately from the creator wanting to know who viewed their story, each viewer’s own client needs to remember which specific stories they’ve already watched, so the story ring can render a visual “already seen” versus “new” distinction. This is naturally modeled as a small, per-viewer set of story IDs they’ve watched, also TTL-bound to expire alongside the stories it references — there’s no reason for a viewer’s “already seen” record for a specific story to persist any longer than the story itself remains relevant.

💬
What an interviewer may ask

“Why does this system favor exact viewer tracking by default, when the earlier profile-view system in this series leaned toward approximate counting?” Because the two systems face genuinely different scale and retention characteristics for the specific data being tracked. Profile views accumulate indefinitely across a user’s entire account lifetime, easily reaching enormous totals over years for a popular profile — a scale where approximate counting’s memory savings become essential. A single story’s viewer set is bounded to a 24-hour window and, for the overwhelming majority of stories, a realistically small audience — exact tracking is both affordable and more useful here, with approximate counting reserved only for the comparatively rare, exceptionally popular case, rather than being the universal default.

08

Data Flow & Lifecycle of a Story

1

Creation

A user captures or selects a photo or short video, and the client uploads it to the Story Service, which validates it (format, size, moderation scanning) and stores the media in blob storage with a lifecycle policy tied to expiry.

2

Metadata write with TTL

A metadata record is written to the TTL-native store, with an explicit 24-hour TTL set directly on the write itself — no separate expiry timestamp field needs to be checked manually on every read, since the storage engine itself enforces it.

3

Ring fanout

A creation event triggers the Story Ring Service to mark the author as having an active story for each follower, using the same TTL principle, so this lightweight indicator naturally expires in lockstep with the underlying content.

4

Viewing

Followers open their story ring, see the new indicator, and tap through to view the story. Each view is recorded in the TTL-bound viewer tracker, and the viewer’s own “already seen” record is updated so their ring reflects the story as watched going forward.

5

Ongoing viewing throughout the 24-hour window

The story remains viewable, its viewer list grows, and its CDN-cached bytes continue being served, throughout its active lifetime — behaving, during this window, much like the always-available content covered in the earlier photo-sharing tutorial, just with a firm expiration looming at a known, fixed point.

6

Expiration

At the 24-hour mark, the TTL-native metadata store makes the story’s metadata inaccessible essentially immediately at the logical level. The active-story ring index entry expires in the same instant, following the same TTL, so the story stops appearing anywhere in the product simultaneously across every surface that referenced it.

7

Backstop cleanup

Independently, the blob storage lifecycle policy removes the underlying media bytes shortly after, providing the defense-in-depth backstop discussed in the architecture section — ensuring the actual content is genuinely gone, not merely unreferenced by the now-expired metadata.

8

CDN cache expiry

Any CDN-cached copies of the story’s media, configured with a cache lifetime bounded by the content’s own 24-hour window (covered in depth in the caching section ahead), stop being served at or before the same moment, closing the last potential gap where expired content could otherwise remain briefly accessible through a stale cached copy.

💬
What an interviewer may ask

“At what point in this lifecycle does the story creator’s ‘who viewed this’ data actually disappear?” It expires alongside the story’s own content, following the same TTL-bound principle established in the viewer tracking section — the viewer set’s TTL is tied to the story’s remaining lifetime at the moment each view is recorded, so viewer data is guaranteed to expire no later than the content itself, and typically at essentially the same moment, rather than lingering as an orphaned record referencing content that no longer exists.

09

Data Model & Storage Schema

erDiagram USER ||–o{ STORY : posts STORY ||–|| MEDIA_OBJECT : references STORY ||–o{ STORY_VIEW : “viewed via” USER ||–o{ STORY_VIEW : “views (as viewer)” STORY { string story_id PK string owner_id FK string media_object_id FK timestamp created_at string audience_type int ttl_seconds } STORY_VIEW { string story_id FK string viewer_id FK timestamp viewed_at } MEDIA_OBJECT { string object_id PK string storage_tier timestamp lifecycle_expiry }
Diagram 3 — Every entity carries an explicit or implicit expiration, unlike permanent entities in earlier tutorials.

9.1 Why the TTL is set at write time, not computed at read time

A tempting but weaker alternative design stores a plain created_at timestamp and computes, on every single read, whether now - created_at > 24 hours. This works correctly, but it pushes the expiration logic into every single piece of application code that ever reads a story, and — critically — it doesn’t give the storage layer itself any way to actually reclaim space or stop serving genuinely expired data on its own; it’s just a filter applied after the fact by application code, which still has to fetch (or at least look at) the technically-expired record to determine that it should be treated as gone. Setting the TTL directly at write time, using the storage engine’s native mechanism, moves this decision into the layer best equipped to enforce it efficiently and consistently, exactly as argued in the dedicated TTL section.

9.2 Grouping multiple stories from one user into a sequence

Users often post several stories within the same day, and the product typically presents them as one continuous, ordered sequence rather than several entirely separate pieces of content. This is handled simply by having each story carry its own independent creation timestamp and TTL, with the client (or a thin sequencing layer) ordering a given user’s currently-active stories by creation time at render time — there’s no need for a separate “story collection” entity with its own lifecycle, since the natural ordering and independent expiration of each individual story already produces the right sequencing behavior without additional bookkeeping.

9.3 Audience type and its interaction with expiration

The audience_type field (public, followers-only, a restricted “close friends” list) affects who can view a story, but deliberately doesn’t affect its expiration behavior — every story expires on the same 24-hour schedule regardless of audience restriction. Keeping these two concerns — access control and expiration — fully independent of each other in the data model avoids a whole category of subtle bugs that could otherwise arise from an audience-dependent expiration rule interacting unexpectedly with the TTL mechanics covered throughout this tutorial.

💬
What an interviewer may ask

“If a platform later wanted to let users manually delete a story before its natural 24-hour expiration, how would that interact with this TTL-based design?” Cleanly — most TTL-native stores support an explicit early delete operation alongside the TTL mechanism, and issuing one simply removes the record immediately rather than waiting for the TTL to elapse naturally. The dependent structures — the ring index entry, the viewer set — would need their own corresponding early-delete calls triggered by the same user action, following the same defense-in-depth principle discussed in the architecture section, rather than assuming the TTL alone will eventually catch everything on a timeline the user explicitly asked to shorten.

10

Storage Systems Deep Dive

DataStore typeWhy
Story metadataTTL-native store (Redis, Cassandra, or DynamoDB)Native, efficient automatic expiration is the single most important capability needed here
Story media bytesObject/blob storage with lifecycle policyDurable, scalable binary storage, with a backstop deletion mechanism independent of the metadata layer
Active-story ring indexRedis (sorted sets, as shown earlier)Needs fast per-viewer reads and supports the score-based expiry-emulation pattern for individual members
Viewer setsRedis (sets, TTL-bound)Natural deduplication, fast membership operations, TTL alignment with content lifetime
User/account dataRelational DB (sharded)Strong consistency valuable for account data that has nothing to do with the ephemeral content itself

10.1 Why Redis, specifically, is such a strong fit for the majority of this system

Nearly every fast-moving, short-lived structure in this design — story metadata (if choosing Redis over Cassandra/DynamoDB for that layer), the ring index, viewer sets — shares a common shape: bounded lifetime, high read frequency, and a natural fit with Redis’s built-in data structures (sorted sets, plain sets, simple key-value with TTL). This mirrors a similar observation made in the profile-view tracking tutorial in this series, and it’s worth restating as a general pattern: whenever a system’s core data has a naturally bounded, relatively short lifetime and needs to be read very frequently, an in-memory, TTL-native store is very often the right default reach, rather than a durable, disk-backed system built with permanence as its core assumption.

10.2 When Cassandra or DynamoDB might be preferred over Redis for the primary story store

Despite Redis’s strong fit for most of this system, the primary story metadata store specifically might reasonably favor Cassandra or DynamoDB instead, for one important reason: durability expectations. Redis is fundamentally an in-memory store — even with persistence options enabled, it’s not always the first choice for data a product wants to guarantee survives a node failure without any risk of loss during the (admittedly short) 24-hour window that data needs to remain reliably available. Cassandra and DynamoDB, being disk-backed, distributed, and replicated by design, offer stronger durability guarantees for the primary content record, while a bounded, less strictly-durability-sensitive supporting structure like the ring index or viewer set can reasonably still live in Redis, accepting a small amount of additional risk in exchange for speed, since losing a viewer count briefly is a far smaller problem than losing the story content itself.

10.3 Blob storage lifecycle policies as a backstop, not the primary mechanism

Most object storage services support configuring an automatic deletion policy on objects after a specified duration, entirely independent of any application-level logic. Using this as a second, independent expiration mechanism for the actual media bytes — set to trigger shortly after the metadata layer’s own TTL — provides genuine defense in depth: even in a scenario where a bug caused the metadata layer’s TTL to somehow not fire correctly, the underlying bytes would still be removed on their own independent schedule, closing that gap rather than relying on a single point of enforcement for a requirement this important to the product’s core promise.

💬
What an interviewer may ask

“If durability matters enough to consider Cassandra or DynamoDB over Redis for story metadata, why not just use one of those for everything in this system, including the ring index and viewer sets?” Because those supporting structures have different priorities — they benefit far more from Redis’s speed and rich native data structures (sorted sets with score-based trimming, simple set operations) than they need Cassandra or DynamoDB’s stronger durability guarantees. A brief, rare loss of ring-index or viewer-count data is a minor, recoverable product inconvenience; a lost story is a more meaningful failure of the core product promise. Matching each piece of data’s actual durability requirement to an appropriately-suited storage system, rather than defaulting to one system everywhere, is the same principle this tutorial series applies consistently across every design.

11

Caching & CDN Strategy

An earlier tutorial in this series built an entire caching philosophy around indefinite, aggressive caching, justified by content being genuinely immutable and permanent. This system needs almost the opposite discipline: caching still matters enormously for performance, but every cache lifetime needs to be explicitly bounded to respect the content’s own expiration, never allowed to accidentally outlive it.

11.1 CDN cache TTL must never exceed content lifetime

When story media is served through a CDN, the cache-control headers governing how long an edge location may retain a cached copy need to be set conservatively relative to the content’s remaining lifetime — commonly by setting an explicit expiration time (rather than a simple max-age duration) that matches the story’s own known 24-hour cutoff, so that even a PoP that cached the content near the very start of its life doesn’t inadvertently continue serving it past the moment it should have expired.

StoryCacheHeaderBuilder.java — remaining-lifetime max-agejava
// Setting cache headers with an absolute expiration matching
// the story's own TTL, rather than a fixed relative max-age
public class StoryCacheHeaderBuilder {

    public void applyCacheHeaders(HttpResponse response, Story story) {
        Instant expiresAt = story.getCreatedAt().plus(Duration.ofHours(24));
        long secondsUntilExpiry = Duration.between(Instant.now(), expiresAt).getSeconds();

        if (secondsUntilExpiry <= 0) {
            response.setHeader("Cache-Control", "no-store");
            return;
        }

        // max-age reflects only the *remaining* lifetime, not a flat 24h,
        // since a request made 20 hours after posting should only be
        // cacheable for the 4 hours actually left, not a fresh 24h window.
        response.setHeader("Cache-Control", "public, max-age=" + secondsUntilExpiry);
        response.setHeader("Expires", HttpDateFormatter.format(expiresAt));
    }
}

Notice the deliberate detail in the code above: max-age is computed as the story’s remaining lifetime at request time, not a flat 24 hours applied to every request regardless of when it happens to occur. A request arriving 20 hours after posting should only be cacheable for the 4 hours genuinely remaining — caching it for a fresh 24-hour window at that point would directly violate the content’s actual expiration promise.

11.2 Why this system still benefits enormously from a CDN, despite the bounded caching

It might seem like the bounded, shrinking cache lifetime here makes a CDN less valuable than in the permanent-content case, but the opposite is largely true: stories see the overwhelming majority of their total views concentrated in the hours immediately following posting, while interest is freshest — precisely the period where a CDN’s caching benefit matters most. The cache lifetime shrinking as the story ages simply mirrors the content’s own naturally declining relevance and view volume over that same window, which is a reasonably good match rather than a poor one.

11.3 Client-side caching, with the same bounded discipline

Client applications caching recently-viewed story media locally need to respect the same expiration-aware headers, ensuring a device doesn’t continue rendering a locally-cached copy of a story from local storage past its actual expiration — the same standard HTTP caching semantics used elsewhere in this tutorial series apply directly here, just with a shrinking rather than effectively-infinite max-age value driving the behavior.

11.4 Metadata caching still follows the usual cache-aside pattern

Access-control and audience-type checks on story metadata still benefit from the same cache-aside pattern discussed for other systems in this series, since that data changes relatively rarely within a story’s short lifetime — just with the same TTL-alignment discipline applied consistently, so a cached metadata entry can never outlive the underlying TTL-native record it describes.

💬
What an interviewer may ask

“What’s the actual risk if a CDN edge node’s cached copy of a story briefly outlives the story’s official expiration by a few seconds, due to normal propagation delay in a purge or expiry signal?” It’s a small, generally acceptable risk given the same tolerance-for-imprecision reasoning discussed in the TTL section — a few seconds of lag in a global CDN’s cache expiry propagation is a minor, bounded gap, not a fundamental violation of the product’s privacy promise, provided it’s consistently small and not, say, minutes or hours. The goal throughout this entire design is keeping every layer’s expiration tightly bounded and consistent with the others, not achieving a literally impossible zero-latency, perfectly synchronized deletion across a globally distributed system.

12

Sharding & Load Balancing

12.1 Partitioning by owner, consistent with the pattern used throughout this series

Story metadata, the ring index, and viewer sets all naturally partition by the relevant user ID — owner_id for story content and viewer sets scoped to a specific story’s creator, viewer_id for the ring index — following the same consistent-hashing-with-virtual-nodes approach used generally throughout this tutorial series, keeping the dominant per-user access patterns confined to single shards.

12.2 A distinctive sharding consideration — load naturally self-balances over each 24-hour cycle

Worth noting as a genuinely distinctive property of this system: because content continuously expires roughly as fast as new content arrives (as established in the capacity estimation section), the total amount of “live” data any given shard needs to hold stays relatively stable over time, rather than growing indefinitely the way a shard holding permanent content would. This means shard capacity planning here is less about “how much bigger will this shard need to get over the platform’s lifetime” and more about “does this shard’s steady-state size, given current traffic levels, fit comfortably within its allocated capacity” — a meaningfully simpler, more stable planning problem than the ever-growing shard sizes a permanent-content system has to account for.

12.3 Load balancing the Story Ring Service specifically

Given that story-ring fetches happen on essentially every app open (as reflected in the capacity estimation’s roughly five billion daily ring-fetch requests), this service needs generous, elastic horizontal scaling behind a standard load balancer, similar to the Feed Service in the earlier content-feed tutorial in this series — it’s one of the most frequently-hit read paths in the entire system, even though the underlying data it serves is comparatively lightweight.

12.4 Hot accounts and the same mitigation principle as elsewhere

An account with an unusually large follower count posting a story can still create a temporary hotspot on whichever shard holds that account’s ring-fanout workload, following the same pattern discussed for celebrity accounts and popular content throughout this tutorial series. The same general mitigation — detecting disproportionately large accounts and giving them dedicated capacity, or applying the pull-based threshold approach discussed in the story ring section — applies here just as it does elsewhere, simply at a smaller absolute scale given how lightweight this particular payload is.

💬
What an interviewer may ask

“Given that this system’s total data volume stays roughly stable rather than growing indefinitely, does that mean you’d provision less sharding capacity than for an equivalently-trafficked permanent-content system?” Not necessarily less capacity overall — read and write throughput still need to be provisioned for the same request volumes calculated in the capacity estimation section, regardless of how much total data is resident at any moment. What changes is the growth-planning conversation specifically: capacity for this system needs to track user growth and posting-frequency trends over time, but doesn’t need to additionally account for indefinitely accumulating historical data the way a permanent-content system’s storage tier does — a genuinely different, generally simpler kind of long-term capacity planning.

13

APIs & Microservices Design

EndpointMethodPurpose
/v1/storiesPOSTCreate a new story
/v1/story-ringGETFetch which followed accounts currently have active stories
/v1/stories/{ownerId}GETFetch a specific account’s currently-active story sequence
/v1/stories/{storyId}/viewPOSTRecord that the caller viewed this story
/v1/stories/{storyId}/viewersGETFetch the viewer list, visible only to the story’s owner
GET /v1/story-ring — sample responsehttp
GET /v1/story-ring HTTP/1.1
Authorization: Bearer <token>

Response 200 OK:
{
  "activeStories": [
    { "ownerId": "u_2201", "displayName": "Priya", "hasUnseen": true,
      "expiresAt": "2026-07-27T09:14:00Z" },
    { "ownerId": "u_5521", "displayName": "Marcus", "hasUnseen": false,
      "expiresAt": "2026-07-26T22:03:00Z" }
  ]
}

Notice the response includes an explicit expiresAt timestamp per entry, letting the client itself proactively remove an entry from the displayed ring the moment it passes, without necessarily waiting for the next full API refresh — a small but genuinely useful detail that keeps the UI feeling accurate to the underlying expiration guarantee even between polling intervals or push updates.

13.1 Why viewer list access is restricted at the API layer, not just hidden in the UI

Following the same access-control principle discussed for tiered features in the profile-view tracking tutorial in this series, the viewer-list endpoint should enforce ownership at the API layer itself — returning an authorization error for anyone other than the story’s owner — rather than relying on the client application to simply choose not to display that data to non-owners. An API that returns the data regardless of caller identity, trusting the client to withhold it appropriately, is not a genuine access control boundary at all.

13.2 Microservice boundaries mirror the distinct TTL-driven concerns

The Story Service (creation, primary content), Story Ring Service (lightweight fan-out), and Viewer Tracking Service are kept as distinct, independently-scalable services for reasons consistent with the general microservices reasoning used throughout this tutorial series — they have different load profiles (ring fetches happen far more often than story creation), different storage backends, and different durability requirements, and splitting them allows each to be scaled, deployed, and reasoned about independently.

💬
What an interviewer may ask

“Should the view-recording endpoint be synchronous, blocking the client until the view is durably recorded, or fire-and-forget?” Fire-and-forget is the better default here, consistent with the asynchronous-write philosophy used throughout this tutorial series — a viewer opening a story shouldn’t have their viewing experience delayed by waiting for a view record to be durably written, especially since the consequence of occasionally losing a view record (a slightly undercounted view total) is a minor, acceptable imperfection, not a correctness problem serious enough to justify adding latency to the actual content-viewing experience.

14

Design Patterns & Anti-patterns

14.1 Patterns worth naming explicitly

Pattern

✓ Native TTL over custom sweep logic

The single most important pattern in this entire tutorial: prefer a storage engine’s built-in expiration mechanism over any custom, application-level scheduled deletion job.

Pattern

✓ TTL propagation across dependent structures

Aligning the ring index’s and viewer set’s expiration with the underlying content’s own remaining lifetime, rather than giving each structure an independent, potentially mismatched TTL, keeps the whole system’s expiration behavior consistent without needing explicit reconciliation logic.

Pattern

✓ Defense-in-depth deletion

Layering an independent blob storage lifecycle policy underneath the primary metadata TTL, so a single mechanism’s failure doesn’t result in a permanent privacy violation.

Pattern

✓ Matching cache lifetime to content lifetime

Explicitly bounding every cache layer’s TTL by the underlying content’s own remaining lifetime, rather than defaulting to the aggressive, indefinite caching appropriate for genuinely permanent content.

14.2 Anti-patterns to avoid

✗ Periodic sweep-based deletion

  • Used as the primary expiration mechanism, rather than using native TTL support — scales poorly and offers weaker precision than the storage engine’s own built-in mechanism.

✗ Computing expiration at read time

  • Stored creation timestamp checked at every read, rather than setting an explicit TTL at write time — this pushes expiration logic into every reader and gives the storage layer no way to actually reclaim space or enforce the boundary on its own.

✗ Reusing indefinite caching philosophy

  • Applying long, aggressive cache lifetimes designed for immutable, permanent content directly to ephemeral content risks serving expired content past its intended lifetime — a direct, visible violation of the product’s core promise.

✗ Drifting dependent-structure TTLs

  • Letting the ring index or viewer sets use independently-computed, potentially mismatched TTLs rather than deriving them from the same source of truth.

✗ Viewer lists exposed without API-layer checks

  • Relying on the client application alone to withhold data that should genuinely be access-controlled server-side.
💬
What an interviewer may ask

“If you inherited a stories system built around a periodic sweep job for expiration, what would be your first, lowest-risk step toward migrating it to native TTL?” Start by adding native TTL as a secondary, parallel expiration mechanism alongside the existing sweep job — set the TTL slightly longer than the sweep job’s own deletion window initially, so the sweep job remains the effective primary mechanism at first while you validate the native TTL behaves correctly in production. Once confidence is established, tighten the TTL to the actual intended value and decommission the sweep job — the same safe, gradual, parallel-run migration approach discussed for architectural changes elsewhere in this tutorial series, applied to a change with real privacy stakes if rushed.

15

Advantages, Disadvantages & Trade-offs

DecisionAdvantagesDisadvantages
Native TTL for primary storagePrecise, efficient, no separate cleanup job needed; scales naturally with data volumeTies the system to a storage engine that actually supports this well; less granular control over exact deletion timing than fully custom logic would offer
Exact viewer tracking (default) vs approximateMore useful product data (an actual list, not just a count); simple to reason aboutDoesn’t scale as gracefully as approximate counting for the rare, exceptionally popular account, requiring a hybrid fallback
Bounded, shrinking CDN cache lifetimeRespects the content’s expiration promise precisely; still captures most of the caching benefit, since views concentrate early in a story’s lifeMore complex cache-control logic than a simple flat max-age value; requires computing remaining lifetime per request
Defense-in-depth deletion (metadata TTL + blob lifecycle policy)Meaningfully reduces the risk of a single point of failure causing a lasting privacy violationTwo independent mechanisms to configure and keep consistent, rather than one

The overarching trade-off worth stating plainly: this design accepts additional configuration complexity — multiple, carefully-aligned TTL mechanisms across several storage and caching layers — specifically in exchange for a genuinely strong, technically-enforced privacy guarantee, rather than relying on a simpler but weaker approach (like a periodic sweep job) that would be easier to build but offer meaningfully less precise, less reliable expiration.

💬
What an interviewer may ask

“Is there a version of this system that trades away some precision in expiration timing for significantly less implementation complexity, and would that ever be an acceptable choice?” Yes — for a smaller platform without this tutorial’s stated massive scale, a simpler periodic sweep job running frequently (say, every minute) might offer acceptable-enough precision with far less architectural complexity than fully wiring native TTL support through every storage and caching layer. The right choice genuinely depends on scale and how strictly the product wants to guarantee its expiration promise — this tutorial’s emphasis on native TTL throughout is justified specifically by the millions-of-daily-views scale stated in the original prompt, where a sweep-based approach’s scaling and precision limitations become real, not hypothetical, problems.

15.1 A trade-off worth naming honestly — engineering rigor versus the appearance of simplicity

It would be easy to present this tutorial’s design as an obviously correct, unambiguous improvement over a simpler sweep-based alternative in every respect, but that framing undersells a genuine cost worth acknowledging directly. Wiring native TTL support consistently through several distinct storage and caching layers, keeping them all aligned to the same source of truth, and building dedicated monitoring specifically for expiration precision, is meaningfully more upfront engineering investment than a single, centralized sweep job that a team could reason about by reading one piece of code in one place. The sweep-based approach’s weaknesses only become genuinely costly at real scale — for a team building this feature for the first time on a small platform, it’s entirely reasonable to start simpler and deliberately plan a migration toward the more robust, TTL-native architecture covered throughout this tutorial only once actual growth and real production experience justify the additional complexity, rather than over-engineering for a scale the product hasn’t reached yet.

16

Performance & Scalability

16.1 Horizontal scaling, consistent with the rest of this tutorial series

Every stateful component here scales horizontally following the same consistent-hashing-based sharding principles applied throughout this series — more Redis shards, more Cassandra/DynamoDB capacity, and more Story Ring Service instances as traffic grows, with the genuinely distinctive advantage, established in the capacity estimation section, that total resident data volume stays roughly stable rather than growing indefinitely alongside platform age.

16.2 Why native TTL support is also a meaningful performance win, beyond correctness

It’s worth restating a point from the dedicated TTL section through a pure performance lens: a sweep-based deletion approach doesn’t just risk imprecision, it also imposes a real, continuously growing computational cost as content volume increases, since each sweep cycle has to examine an ever-larger dataset. Native TTL support avoids this scaling problem entirely — expiration cost is handled incrementally by the storage engine’s own internal mechanisms (lazy checks, tombstones and compaction, background scanning decoupled from application-visible capacity) rather than through a separate, increasingly expensive batch operation that competes with normal read/write traffic for resources.

16.3 Read-path optimization for the story ring specifically

Given how frequently the story ring is fetched (as established in the capacity estimation section, on the order of billions of requests daily), keeping this specific read path as lightweight as possible matters enormously — the sorted-set-based approach shown in the ring section, returning a compact list of active accounts with minimal per-entry data, is deliberately kept far leaner than the equivalent feed-assembly work covered in the content-feed tutorial in this series, precisely because it’s called so much more frequently per user session.

16.4 Batch fanout, applied at the smaller scale relevant here

The same batching and pipelining techniques used for feed fan-out in an earlier tutorial apply directly to the ring-index fanout shown in this tutorial’s code samples — batching writes and using pipelined commands rather than one-at-a-time network round trips, even though the absolute follower counts involved here are typically far smaller than the celebrity-scale numbers that made batching essential in that earlier system.

💬
What an interviewer may ask

“If posting volume suddenly tripled — say, due to a major cultural or news event driving a huge wave of story posting — what would you expect to scale, and what wouldn’t need to change?” Write-path capacity across the primary story store, the ring-fanout pipeline, and the event queue would need to scale up proportionally, following the same reasoning applied to write-volume spikes in earlier tutorials in this series. What notably wouldn’t need fundamental rearchitecting: the underlying TTL mechanics themselves, since native expiration support scales naturally with data volume rather than needing a proportionally larger custom cleanup process — one of the clearest practical benefits of relying on the storage engine’s own built-in mechanism rather than custom logic.

17

High Availability, Reliability & the CAP Theorem

17.1 Where this system sits on the CAP spectrum

Consistent with most systems throughout this tutorial series, this design leans AP — favoring availability over strict consistency for the large majority of operations. A story ring that’s a few seconds stale, or a viewer count that hasn’t fully caught up with the very latest view, are both acceptable imperfections in exchange for the system remaining available and responsive.

17.2 A genuinely distinctive nuance — availability and expiration can occasionally pull in different directions

Worth naming explicitly, since it’s a nuance specific to this system among those covered in this tutorial series: there’s a subtle tension between “favor availability” and “guarantee timely expiration.” If a storage replica has been temporarily partitioned from the rest of the cluster and reconnects after some delay, it might serve a story that should have already expired according to the TTL, simply because that replica’s clock or replicated state briefly lagged behind. This doesn’t undermine the AP-leaning default established throughout this series, but it does mean the expiration guarantee, like every other consistency guarantee in an AP-leaning system, is itself eventually consistent rather than instantaneously and universally enforced the microsecond the TTL elapses across every single node globally.

17.3 Replication for durability, especially for the primary content store

As discussed in the storage systems section, the primary story metadata store benefits from the same kind of multi-replica durability protection used throughout this tutorial series — losing a story before its natural expiration due to an infrastructure failure is a real, visible product failure, distinct from and arguably worse than the acceptable staleness tolerated elsewhere in an AP-leaning design.

17.4 Graceful degradation specific to this system

  • If the Story Ring Service is degraded, the app can fall back to showing a simpler, less personalized indicator (or briefly showing no ring at all) rather than failing entirely, following the same graceful-degradation principle used for ranking and other secondary concerns elsewhere in this tutorial series.
  • If the Viewer Tracking Service is degraded, story viewing itself is entirely unaffected — a viewer can still watch a story even if their view fails to be recorded, since view recording is explicitly asynchronous and non-blocking, as established in the APIs section.
  • If a specific storage shard holding ring or viewer data experiences a temporary partition, the affected functionality degrades gracefully to a smaller, isolated blast radius rather than the whole system becoming unavailable, consistent with the general sharding-and-isolation principles used throughout this tutorial series.
💬
What an interviewer may ask

“Given the tension you mentioned between availability and precise expiration, would you ever favor consistency over availability specifically for the expiration mechanism?” It’s reasonable to consider a middle ground rather than an absolute choice: keeping the overall system AP-leaning for general availability, while treating the expiration guarantee specifically as something worth monitoring closely (covered in the next section) and bounding tightly, even if not treating it as requiring the kind of strict, synchronous cross-node consensus that a genuinely CP-leaning system would use. In practice, the small, bounded staleness windows this tolerance allows for are generally acceptable for the product’s actual privacy promise, provided they stay small and are actively monitored rather than growing unnoticed.

18

Security & Privacy

Privacy is arguably even more central to this feature’s core value proposition than most others covered in this tutorial series — the entire product premise is built around a promise that content genuinely disappears, and every security consideration here ultimately traces back to protecting that specific promise.

18.1 Audience restriction, independent of but as important as expiration

Beyond the time-based expiration this tutorial focuses on heavily, stories typically support audience restrictions — followers-only, a curated “close friends” subset, or fully public — enforced through the same kind of authorization checks discussed for private content in the earlier photo-sharing tutorial in this series, including signed, time-limited URLs for accessing story media, so that even a leaked direct link carries a bounded exposure window, consistent with that earlier tutorial’s approach.

18.2 Preventing unauthorized capture — an inherently limited but still worthwhile mitigation

Products in this space often attempt to detect or discourage a viewer taking a screenshot or screen recording of ephemeral content, notifying the creator when this happens. It’s worth being honest about the actual security properties here: this is fundamentally a best-effort, client-side detection mechanism, not a robust technical guarantee — a sufficiently motivated viewer can generally capture on-screen content through means the app has no visibility into (a second device’s camera, for instance), and platforms are generally upfront that this is a deterrent and transparency signal rather than an ironclad prevention mechanism.

18.3 Replay and re-sharing considerations

Because story media itself is technically just another blob object during its active window, a viewer’s client necessarily has the bytes available locally while viewing, which means preventing re-sharing or redistribution is, similarly to screenshot prevention, fundamentally limited by what a client-side application can enforce. Platforms typically address this primarily through product policy and terms of service rather than pretending a purely technical guarantee against redistribution is achievable once content has been rendered on a viewer’s device.

18.4 Ensuring deleted content doesn’t leak through backups or logs

A subtlety worth calling out explicitly: standard infrastructure practices elsewhere in a platform — database backups, application logs that might incidentally capture request payloads, analytics pipelines — can inadvertently retain a copy of content or metadata that the primary system has otherwise correctly expired, undermining the deletion guarantee through a side channel the TTL mechanisms discussed throughout this tutorial don’t touch. A genuinely rigorous implementation needs explicit retention policies for backups and logs involving this content, generally keeping raw content out of general-purpose logging entirely and applying a comparably short retention window to any necessary backups of the primary content store.

18.5 Content moderation, on a compressed timeline

Automated content moderation scanning, covered in general terms in the earlier photo-sharing tutorial, needs to operate quickly here specifically because the content’s entire viewing window is short — a moderation process that takes hours to flag policy-violating content is considerably less useful for a piece of content that’s only visible for 24 hours in the first place, compared to the same delay for permanent content that will remain reviewable indefinitely. This timing pressure is worth taking seriously as a genuine design constraint in its own right, not merely a minor inconvenience: a moderation pipeline tuned for a permanent-content platform’s more relaxed timeline, where a flagged post can be reviewed and removed days later with little lost value in catching it late, needs real rearchitecting to meet this system’s much tighter effective deadline, where the majority of a story’s total viewing activity may well have already happened before a slower moderation process would even have finished its review.

💬
What an interviewer may ask

“A regulator or legal request requires you to produce a specific expired story as evidence — is that even possible given how aggressively this system deletes content?” This is exactly why many real platforms maintain a separate, access-restricted compliance retention store — distinct from the user-facing product surface, not shown to any regular user or even the content’s own creator through normal product surfaces, and governed by its own, typically much longer, legally-driven retention policy. This mirrors the same anonymous-viewer-identity-retention distinction discussed in the profile-view tracking tutorial in this series: the platform can retain what it legally needs to for compliance purposes, in a deliberately separate system, without that retention undermining the ordinary user-facing deletion promise this tutorial’s core design delivers.

19

Monitoring, Logging & Observability

19.1 The metric unique to this system — expiration precision itself

Beyond the usual latency and error-rate metrics common across every system in this tutorial series, this design benefits from a metric that doesn’t have a close analog elsewhere: directly measuring the gap between a story’s intended expiration time and the moment it actually became inaccessible across each layer (primary store, ring index, CDN cache). Sampling a set of recently-expired stories and confirming they’re genuinely inaccessible everywhere, promptly, turns the core privacy promise from an assumption into something actively, continuously verified in production.

19.2 Alerting on expiration drift specifically

A dedicated alert triggering if the observed gap between intended and actual expiration exceeds a defined tolerance (informed by the same kind of “small, bounded imprecision is acceptable” reasoning established in the TTL and CAP sections) is worth having as a standing, ongoing safeguard — this is the one metric in this entire system where a regression has real, direct privacy consequences for users, not merely a performance inconvenience, which justifies a lower alerting threshold and faster required response than a typical latency regression would warrant elsewhere in the platform.

19.3 Standard pipeline health metrics, applied here as elsewhere

  • Story-ring fetch latency (p50/p95/p99) — given how frequently this endpoint is called, even small regressions are felt broadly across the platform’s core browsing experience, mirroring the same emphasis placed on feed-open latency in the content-feed tutorial in this series.
  • Ring-fanout event processing lag — a growing backlog here means newly-posted stories take longer than intended to appear in followers’ rings, delaying the product’s core “see what’s new” promise.
  • Storage volume, tracked as a steady-state metric rather than a growth trend — unlike the ever-increasing storage growth charts relevant to a permanent-content system, a healthy version of this system should show storage volume oscillating around a relatively stable baseline; a sustained upward drift here is itself worth investigating, since it may indicate a TTL misconfiguration causing content to linger longer than intended, rather than simply reflecting expected platform growth.

19.4 Distributed tracing across the creation-to-expiration lifecycle

A trace ID following a single story from creation through ring fanout, viewing, and eventual expiration — the same distributed tracing principle applied to other multi-service pipelines throughout this tutorial series — makes it possible to diagnose specific questions like “why did this particular story remain visible slightly past its intended expiration” by following its actual path through every layer, rather than reconstructing the timeline from disconnected logs after the fact.

💬
What an interviewer may ask

“How would you build confidence, before launch, that your expiration guarantee actually holds up under real production conditions, not just in a controlled test environment?” Run a continuous, low-volume synthetic monitoring process in production itself — periodically creating test stories with known creation times, then verifying at the expected expiration moment (and shortly after) that they’ve genuinely become inaccessible across every layer: primary store, ring index, and CDN cache. This kind of active, ongoing synthetic verification, rather than relying solely on unit or integration tests run before deployment, is what actually validates the guarantee holds under real infrastructure conditions, including whatever timing variance and replication lag genuinely exists in the live system.

20

Deployment & Cloud Architecture

20.1 Standard containerized deployment, following the pattern used throughout this series

The Story Service, Story Ring Service, and Viewer Tracking Service all follow the same containerized, orchestrator-managed deployment pattern used consistently across every system in this tutorial series, each scaling independently based on its own relevant load signal.

20.2 Multi-region deployment, with an added TTL-consistency consideration

Beyond the usual reasoning for multi-region deployment — reducing latency for a geographically distributed user base, and providing resilience against a regional outage — this system has one additional wrinkle worth calling out: if story metadata is replicated across regions, clock skew or replication lag between regions could theoretically cause a story to appear to expire at slightly different moments in different regions, depending on which replica a given request happens to be served from. Using a consistent, well-synchronized time source for computing TTLs (rather than relying on each individual region’s local clock independently) and keeping the same small-tolerance mindset established throughout this tutorial helps keep this variance within the same acceptable bounds discussed in the CAP theorem section, rather than letting it grow into a more noticeable inconsistency.

20.3 CI/CD for TTL-related configuration changes specifically

Given how directly TTL configuration ties to this product’s core privacy promise, changes to TTL values, expiration logic, or cache-header computation deserve particularly careful, gradual rollout — a canary deployment validated against the same kind of synthetic expiration-verification monitoring discussed in the observability section, rather than trusting standard functional tests alone to catch a subtle regression in exactly how long content actually remains accessible.

20.4 Regional CDN configuration consistency

Because cache-control headers are computed once by origin infrastructure but interpreted independently by many geographically distributed CDN edge nodes, ensuring consistent CDN configuration across all regions — the same expiration-respecting header logic applied uniformly everywhere, rather than region-specific configuration drift — is an important, easy-to-overlook operational discipline specific to this system’s privacy-sensitive caching requirements.

💬
What an interviewer may ask

“Why does clock synchronization across regions matter more for this system than for the other systems in this tutorial series?” Because this system’s core correctness property is explicitly time-based — “expires after exactly 24 hours” — in a way that most other systems in this series aren’t. A feed system’s eventual consistency tolerance doesn’t depend on precise time synchronization the same way; a story’s expiration guarantee directly does, since the entire mechanism is built around comparing the current time against a stored creation or expiry timestamp. Meaningful clock skew between regions could translate directly into meaningfully different, user-visible expiration behavior depending on which region happens to serve a given request, which is exactly the kind of inconsistency this tutorial’s emphasis on small, bounded, well-understood tolerances is meant to keep in check.

21

Disaster Recovery, Backup & Cost Optimization

21.1 Disaster recovery, with a genuinely unusual twist

Most disaster recovery discussions in this tutorial series focus on ensuring data survives an infrastructure failure without loss. Here, there’s a real, worth-naming tension: standard disaster recovery practice (frequent backups, cross-region replication) protects content from being lost prematurely, but this product’s core promise is that content should stop existing after 24 hours regardless — meaning disaster recovery planning here needs to protect against premature loss during a story’s active window, while very deliberately not accidentally extending content’s life past its intended expiration through an overly aggressive backup or recovery process.

21.2 Backup strategy, scoped tightly to the content’s own short lifetime

Given how short-lived the primary content is, traditional long-retention backup strategies (daily snapshots kept for 30 days, as discussed for permanent-content systems in earlier tutorials) don’t make sense to apply uniformly here — a backup of story metadata should itself carry a comparably short retention window, consistent with the content it protects, rather than inadvertently creating a longer-lived shadow copy of content the product explicitly promises will disappear. Any exception to this — the compliance retention store discussed in the security section — needs to be a deliberate, clearly separated, access-restricted system, not an accidental byproduct of applying a generic backup policy without adjustment.

21.3 Cost optimization — a genuinely different profile than the permanent-content case

  • No storage tiering needed for the primary content — as established in the storage systems section, nothing here persists long enough to justify migrating to progressively cheaper cold-storage tiers, which meaningfully simplifies (and reduces the cost of) the storage architecture compared to a permanent-content platform.
  • Compression still matters, just over a shorter total lifetime — efficient media compression reduces both storage footprint during the active window and CDN bandwidth cost, following the same reasoning as the earlier photo-sharing tutorial, just amortized over a much shorter content lifetime.
  • Right-sizing capacity around a stable steady state, not a growth curve — because total data volume stays roughly constant rather than growing indefinitely, capacity planning can focus on current traffic levels and near-term user growth, rather than needing to provision years of accumulating headroom the way a permanent-content system’s storage tier does.
  • CDN cost naturally scales down as content ages — since cache lifetime shrinks as a story approaches its expiration (as covered in the caching section), the system doesn’t pay for extended, low-value caching of content nearing the end of its relevance, aligning cost with actual likely future demand reasonably well.
💬
What an interviewer may ask

“Given how different this system’s cost profile is from the permanent-content photo-sharing system covered earlier in this series, would you expect this system to be meaningfully cheaper to run at equivalent traffic levels?” Likely somewhat cheaper on the storage side specifically, given the bounded, non-accumulating storage footprint and the reduced need for tiered storage infrastructure — but likely comparable on the compute and serving side, since read traffic volume (which dominates infrastructure cost in most systems throughout this tutorial series) is driven by viewing patterns and user engagement, not by how long content happens to persist afterward. The genuinely different cost profile here is concentrated specifically in storage and long-term capacity planning, not in the serving infrastructure that handles the bulk of day-to-day traffic.

22

Algorithms Deep Dive

22.1 How Redis actually implements TTL internally

It’s worth understanding the actual mechanism behind Redis’s expiration behavior, since interviewers often probe past “just call EXPIRE” into how it really works. Redis uses two complementary strategies simultaneously. Lazy expiration checks a key’s expiry timestamp at the moment it’s actually accessed — if a read or write touches a key whose TTL has passed, Redis treats it as if it doesn’t exist and removes it right then, guaranteeing no client can ever retrieve genuinely expired data, regardless of whether background cleanup has gotten to it yet. Active expiration runs as a periodic background cycle that samples a small number of keys with TTLs set, removing any that have expired, specifically so that keys which are never accessed again after expiring don’t simply sit in memory forever — without this second mechanism, a key nobody ever reads again after its TTL passes would never actually get cleaned up through the lazy path alone, since that path only triggers on access.

SimplifiedTTLStore.java — lazy + active expirationjava
// A simplified illustration of the two-part expiration strategy -
// production Redis is considerably more optimized, but this
// captures the essential dual mechanism.
public class SimplifiedTTLStore {

    private final Map<String, String> data = new HashMap<>();
    private final Map<String, Long> expiryTimestamps = new HashMap<>();

    public void set(String key, String value, Duration ttl) {
        data.put(key, value);
        expiryTimestamps.put(key, System.currentTimeMillis() + ttl.toMillis());
    }

    // Lazy expiration: check on every read, regardless of background cleanup
    public Optional<String> get(String key) {
        if (isExpired(key)) {
            removeKey(key);
            return Optional.empty();
        }
        return Optional.ofNullable(data.get(key));
    }

    // Active expiration: periodically sample and remove expired keys,
    // so unread expired keys don't linger indefinitely in memory
    public void runActiveExpirationCycle(int sampleSize) {
        List<String> sample = sampleRandomKeys(expiryTimestamps.keySet(), sampleSize);
        for (String key : sample) {
            if (isExpired(key)) {
                removeKey(key);
            }
        }
    }

    private boolean isExpired(String key) {
        Long expiry = expiryTimestamps.get(key);
        return expiry != null && System.currentTimeMillis() > expiry;
    }

    private void removeKey(String key) {
        data.remove(key);
        expiryTimestamps.remove(key);
    }

    private List<String> sampleRandomKeys(Set<String> keys, int count) {
        List<String> list = new ArrayList<>(keys);
        Collections.shuffle(list);
        return list.subList(0, Math.min(count, list.size()));
    }
}

22.2 Timing wheels — a more advanced structure for scheduling precise, high-volume expirations

For systems that need to schedule and precisely trigger a very large number of time-based events efficiently — which describes this system’s expiration needs well, given millions of stories expiring continuously throughout each day — a timing wheel is a genuinely useful data structure worth knowing, used internally by several high-performance networking and scheduling systems. The idea: organize scheduled events into a circular buffer of time “buckets,” where each bucket represents a small time interval, and an event is placed into the bucket corresponding to when it should fire. A single pointer advances around the wheel over time, and whenever it reaches a bucket, every event scheduled in that bucket fires. This achieves close to constant-time insertion and firing, dramatically more efficient than repeatedly scanning a large, unsorted list of pending expiration times to find what’s due next.

TimingWheel.java — bucketed schedulerjava
// A simplified timing wheel for scheduling expiration callbacks
public class TimingWheel {

    private final List<List<Runnable>> buckets;
    private final int bucketDurationMillis;
    private int currentBucket = 0;

    public TimingWheel(int numBuckets, int bucketDurationMillis) {
        this.bucketDurationMillis = bucketDurationMillis;
        this.buckets = new ArrayList<>();
        for (int i = 0; i < numBuckets; i++) {
            buckets.add(new ArrayList<>());
        }
    }

    public void scheduleExpiration(Runnable onExpire, long delayMillis) {
        int ticksAhead = (int) (delayMillis / bucketDurationMillis);
        int targetBucket = (currentBucket + ticksAhead) % buckets.size();
        buckets.get(targetBucket).add(onExpire);
    }

    // Called by a driving clock/scheduler on each tick of the wheel
    public void advanceTick() {
        List<Runnable> dueNow = buckets.get(currentBucket);
        for (Runnable callback : dueNow) {
            callback.run(); // fire the expiration action
        }
        dueNow.clear();
        currentBucket = (currentBucket + 1) % buckets.size();
    }
}

This is a genuinely useful structure to be aware of conceptually, even though this tutorial’s primary recommendation remains leaning on a storage engine’s own native TTL support (as covered at length in the dedicated TTL section) rather than building custom scheduling infrastructure from scratch — the timing wheel is, not coincidentally, very similar in spirit to the kind of mechanism a storage engine’s own internal active-expiration cycle is likely built on. Understanding it helps explain why native TTL support scales as well as it does, rather than treating it as an unexplained black box.

22.3 Comparing expiry mechanisms side by side

ApproachTime complexity per expiry checkPrecision
Naive full sweepO(N) per sweep cycle, over all recordsBounded by sweep interval — imprecise, and worsens as N grows
Lazy expiration onlyO(1) per access, but never cleans up unread keysPrecise for accessed keys; unbounded memory growth for unread expired keys
Timing wheel / native active expirationClose to O(1) amortized, independent of total dataset sizeBounded by tick granularity — typically sub-second, tunable
💬
What an interviewer may ask

“Why does Redis need both lazy and active expiration, rather than just relying on one or the other?” Because each one alone leaves a real gap. Lazy expiration alone guarantees correctness for anything actually read, but does nothing for expired keys nobody ever accesses again, letting them silently consume memory indefinitely. Active expiration alone, running only periodically, could theoretically let a client read a technically-expired-but-not-yet-swept key in the brief window between expiry and the next active cycle. Together, they cover both gaps: lazy expiration guarantees no stale read ever slips through, and active expiration guarantees memory actually gets reclaimed even for data nobody ever touches again after it expires.

22.4 Why this section matters beyond passing an interview question

It’s worth stepping back and noting why understanding these mechanisms at this level of depth actually matters in practice, not just as interview trivia. A team that understands lazy versus active expiration, and why a timing wheel scales the way it does, is much better equipped to reason correctly about the actual guarantees their chosen storage engine provides — and, just as importantly, about where those guarantees have edges. Knowing that active expiration works probabilistically, by sampling, rather than by exhaustively checking every expired key on every cycle, for instance, helps explain why a very small number of expired-but-unaccessed keys might occasionally persist slightly longer than expected in memory even with native TTL support fully enabled, which is exactly the kind of nuance that separates confidently using a tool correctly from merely knowing its name.

23

Best Practices & Common Mistakes

23.1 Best practices worth internalizing

The following practices generalize well beyond this specific feature, and are worth carrying into any system where time-bounded correctness genuinely matters.

  • Always reach for a storage engine’s native expiration support first. This is the single most important, broadly-applicable lesson from this entire tutorial — custom expiration logic is very rarely a better choice than a well-established storage engine’s own built-in mechanism.
  • Derive dependent structures’ TTLs from the same source of truth, rather than giving each one an independently-computed expiration that could drift out of sync with the content it describes.
  • Treat expiration precision as a monitored, alertable metric in its own right, not an assumption you build once and trust indefinitely without verification.
  • Match caching aggressiveness to content lifetime explicitly, rather than defaulting to caching patterns designed for permanent content.
  • Build defense-in-depth for deletion specifically, given the real privacy consequences of getting this particular guarantee wrong, following the layered approach (metadata TTL plus independent blob lifecycle policy) covered throughout this tutorial.

23.2 Common mistakes

The following patterns show up repeatedly enough across real implementations of this kind of feature that they’re worth naming explicitly, so they can be caught during design review rather than discovered later in production.

  • Reaching for a periodic sweep job as the primary expiration mechanism, rather than a storage engine’s native TTL support, leading to both weaker precision and worse scaling characteristics as data volume grows.
  • Applying a permanent-content caching philosophy without adjustment, risking content remaining accessible past its intended expiration through an overly long cache lifetime.
  • Letting the ring index or viewer tracking structures use independent, disconnected TTLs rather than deriving them from the underlying content’s own remaining lifetime, risking visible inconsistency between what the product shows and what’s actually still valid.
  • Forgetting that standard infrastructure practices — logging, backups — can inadvertently undermine the deletion guarantee through a side channel the primary TTL mechanisms never touch.
  • Treating screenshot or capture prevention as a robust technical guarantee rather than the inherently limited, best-effort deterrent it actually is.
💬
What an interviewer may ask

“If you had to summarize this entire design’s philosophy in one sentence for someone with no context, what would you say?” Something like: “we let the storage systems themselves guarantee content disappears on schedule, rather than building and trusting a separate cleanup process to remember to do it” — nearly every specific decision throughout this tutorial, from the primary content store through the ring index to the CDN cache headers, is really just this one idea applied consistently and carefully at every single layer the content passes through.

24

Real-World / Industry Examples

Origin

The messaging platform that popularized the format

As covered in the introduction, this format originated with a messaging platform’s 2013 feature launch, built specifically around the premise that not every piece of shared content needs to become a permanent record — a genuinely novel product bet at the time, which this tutorial’s engineering approach takes as a given rather than needing to justify from scratch.

Adoption

Major social platforms’ own story formats

Several major social platforms subsequently launched closely analogous features, each independently arriving at broadly similar engineering solutions to the same underlying constraints — automatic expiration, lightweight ring-style discovery of active content, and viewer tracking scoped to the content’s active window — a good real-world signal that the architectural patterns covered throughout this tutorial aren’t specific to one company’s particular implementation choices, but represent a broadly convergent, sound approach to this specific problem shape.

Messaging

Messaging platform “status” features

Messaging-first platforms have implemented closely related ephemeral status update features, generally operating at a smaller scale than the largest social feed platforms but facing the same core technical challenges around reliable, timely expiration covered throughout this tutorial — a useful reminder that this pattern isn’t tied to any one specific product category, but applies broadly to any product built around the premise of temporary, self-expiring shared content.

General

TTL and timing-wheel patterns beyond social

Beyond social products specifically, TTL-based expiration and timing-wheel-style scheduling (covered in the algorithms section) show up broadly across distributed systems wherever something needs to happen reliably on a schedule at scale — session expiration in authentication systems, cache invalidation in general-purpose caching layers, and connection timeout handling in networking libraries all lean on closely related underlying mechanisms, which is part of why this tutorial’s core lesson — prefer a system’s native scheduling and expiration support over custom-built alternatives — generalizes so usefully well beyond this one specific product feature.

💬
What an interviewer may ask

“Given how many different products have independently arrived at similar solutions to this problem, what does that convergence tell you?” It’s a strong signal that the core architectural choices covered in this tutorial — native TTL support, lightweight ring-style fan-out, defense-in-depth deletion — aren’t arbitrary implementation details specific to any one company, but a genuinely well-suited fit for the underlying constraints of the problem itself: massive scale, a hard time-based correctness requirement, and a strong privacy expectation. When multiple independent engineering teams converge on similar solutions to the same problem shape, that convergence itself is useful evidence about which trade-offs are actually well-suited to the problem, beyond any single team’s specific preferences.

25

Frequently Asked Questions

Q1What if a platform wants to let users save a story beyond 24 hours — say, to a permanent “highlights” feature?

This is best modeled as an explicit, separate copy operation rather than an exception carved into the ephemeral system’s own expiration logic. When a user chooses to save a story to a permanent collection, the system creates a new, independent, non-expiring record (following the permanent-content patterns from the earlier photo-sharing tutorial in this series) referencing the same underlying media, rather than trying to selectively cancel or extend the original ephemeral record’s TTL — keeping the ephemeral and permanent storage paths cleanly separated avoids a whole category of complexity that would arise from trying to make one system serve both fundamentally different lifetime requirements.

Q2Does the 24-hour window start from when a story is posted, or from when a specific viewer first sees it?

Overwhelmingly, from posting — every viewer sees the same story for the same window, ending 24 hours after the creator originally posted it, regardless of when any individual viewer happens to watch it. This is the far simpler and more common design, and it’s what every code sample and diagram throughout this tutorial assumes; a per-viewer expiration window would introduce considerably more bookkeeping (a separate expiration clock per viewer, per story) for a viewing model most platforms in this space haven’t found compelling enough to justify that added complexity.

Q3How does this system handle a user in a time zone where “24 hours” might feel ambiguous around daylight saving changes or similar edge cases?

The underlying TTL mechanism should always be computed and stored using an unambiguous, absolute time reference (UTC-based epoch time, as used throughout this tutorial’s code samples) rather than any timezone-relative representation — this sidesteps daylight saving and timezone ambiguity entirely, since “24 hours from this exact UTC instant” is a fully unambiguous, well-defined point in time regardless of which timezone any particular viewer happens to be in when they check.

Q4Could this design be adapted for a different expiration window — say, stories that last 7 days instead of 24 hours?

Very directly — every mechanism covered in this tutorial (native TTL, ring-index alignment, bounded CDN caching) parameterizes cleanly on the expiration duration itself, and none of the core architectural decisions are actually tied to the specific number 24 hours. The one area worth reconsidering for a meaningfully longer window is the storage growth assumption from the capacity estimation section — a 7-day window means roughly seven times more steady-state resident data than a 24-hour window at the same posting rate, which is still bounded and non-accumulating, just at a proportionally larger stable baseline.

Q5What’s the simplest version of this system that could reasonably work for a much smaller platform?

For a platform with modest scale and traffic, a single TTL-native store (Redis alone, without necessarily needing Cassandra or DynamoDB for additional durability) handling both metadata and the ring index, without a dedicated CDN layer, would likely perform perfectly well. The full depth covered in this tutorial — defense-in-depth deletion, careful multi-layer TTL alignment, dedicated expiration-precision monitoring — earns its complexity specifically at the millions-of-daily-views scale described in the original prompt, not as a baseline requirement for implementing this feature at any scale whatsoever.

26

Summary & Key Takeaways

Here’s the narrative worth being able to walk through cleanly, start to finish, if asked to design this system live.

📌
Key takeaways
  1. Recognize this as a system where deletion, not permanence, is the core correctness requirement — a genuinely different starting assumption than most content systems, which flips several usual defaults (aggressive indefinite caching, growth-oriented storage tiering) that don’t apply well here.
  2. Lean on native TTL support in the storage layer wherever it’s available, rather than building custom sweep-based deletion logic — this is the single most important, broadly-applicable lesson this tutorial offers, both for correctness and for how well the mechanism scales as content volume grows.
  3. Derive every dependent structure’s expiration from the same source of truth as the primary content — the ring index, viewer sets, and cache headers should all expire in lockstep with the underlying story, not on independently-computed schedules that could drift apart.
  4. Build defense-in-depth specifically around deletion, given the real privacy stakes if this particular guarantee fails — a single point of enforcement is a meaningfully weaker design than layering independent expiration mechanisms across metadata, blob storage, and caching.
  5. Recognize the genuinely different capacity and cost profile this system enjoys compared to permanent-content platforms — steady-state, non-accumulating storage, simplified tiering needs, and a cost structure dominated by serving traffic rather than ever-growing storage volume.
  6. Monitor expiration precision itself as a first-class, alertable metric, treating the core privacy promise as something to be actively, continuously verified in production, not merely assumed correct because it passed testing before launch.
The one sentence to remember

This system succeeds not by being clever about showing content, but by being disciplined about making it disappear — trusting well-established, native expiration mechanisms at every layer a piece of content touches, rather than building custom logic to reinvent a problem that storage engines built for exactly this purpose already solve efficiently and precisely.

Placed alongside the other systems in this tutorial series — a content feed built around fan-out economics, a profile-view tracker built around write-heavy aggregation, and a photo-sharing platform built around the physics of global content delivery — this ephemeral stories system rounds out the set with a distinctive lesson of its own: sometimes the dominant constraint shaping an entire architecture isn’t about serving more, or serving faster, but about reliably, precisely, and privately making something stop existing exactly when it’s supposed to.

That’s a genuinely useful lesson to carry forward well beyond this specific feature. Most system design discussions, and most real engineering work, focus naturally on how to build things that persist, scale, and serve traffic well — this tutorial’s central case study is a reminder that an equally rigorous, equally interesting engineering discipline exists on the other side of that coin: building systems that keep a precise, trustworthy promise about when and how something should stop existing, which turns out to demand just as much careful, deliberate design as building something meant to last forever.