Scale Interview
System DesignHard

Design Snapchat

Ephemeral Messaging & Disappearing Content System Design

Overview

Send a friend a photo on Snapchat and something unusual happens: they watch it once, and then it's gone — deleted from their phone and, crucially, from Snapchat's servers. Post a Story and it's visible to your friends for exactly 24 hours before it vanishes. The product is the disappearance. That flips the usual social-media design on its head: where Instagram is a read-heavy, store-forever system optimized to accumulate and re-serve content, Snapchat is a write-heavy, delete-heavy, store-briefly system whose central guarantee is that content reliably goes away.

It's labeled "Hard" because the interesting problems live in the parts most candidates hand-wave. "It disappears after viewing" sounds like a client feature — hide the image in the app — but that's not deletion; the bytes are still on the server and CDN, trivially recoverable. Real ephemerality is a server-side lifecycle: each snap tracks per-recipient view state, the media blob is deleted once every recipient has opened it (or after an unopened timeout), and the media is served through short-lived signed URLs so a deleted snap can't be re-fetched from a warm CDN edge. At Snapchat's scale (~400M daily users, billions of snaps a day) the system deletes roughly as fast as it writes — so delete throughput and TTL-driven expiry become first-class design constraints, not afterthoughts.

In this walkthrough you'll scope the system, size it, model the per-recipient lifecycle, design the API, draw the send/receive and story paths, then go deep on the ephemeral deletion guarantee, media delivery for short-lived content (the inverse of the usual CDN strategy), and delivery to offline recipients plus Story fan-out and expiry.

Out of scope (say so): the AR lens/filter pipeline, Discover/publisher content, the recommendation ranking behind Spotlight, ads, and end-to-end encryption key management (we'll note where E2EE would change the design). We keep the core: send a disappearing snap to friends, receive and open it, post a 24-hour Story, and have content reliably deleted.

Functional requirements

  • Send a snap. A user sends a photo or short video to one or more friends with an expiry policy (view-once, or a timer of N seconds).
  • Receive and open a snap. A recipient sees a pending snap in their inbox, opens it, and views the media for the allotted time; after viewing it disappears from their device.
  • Ephemeral deletion. Once all recipients have viewed a snap (or it goes unopened past a maximum window, e.g. 30 days), the media is deleted from the server.
  • Post a Story. A user posts a photo/video to their Story, visible to their friends for 24 hours, then it expires.
  • View friends' Stories. A user loads the list of friends with active (unexpired) Stories and plays them.

Out of scope (state it): AR lenses, Discover, Spotlight ranking, ads, group-chat text messaging beyond snaps, and payments.

Non-functional requirements

  • Deletion is a hard guarantee, not best-effort. Ephemerality is the product's core promise. Viewed and expired media must be deleted from the blob store promptly and reliably — a silently-skipped deletion is a correctness bug, not just stale data.
  • Write- and delete-heavy, not read-accumulating. Unlike a feed system, content is created, viewed a small bounded number of times, and destroyed. The store churns; it does not grow with a permanent read-serving corpus.
  • Low-latency send and open. Sending must feel instant (media upload is async); opening a snap should surface the media within a few hundred milliseconds via CDN.
  • Reliable delivery to offline recipients. A snap sent to an offline friend must be held and delivered when they next come online, then still obey its expiry rules.
  • Availability over strong consistency for receipts/counts. "Seen" receipts and Story view counts can lag or be approximate. But the deletion lifecycle must not lose track of a blob that still needs deleting.
  • Privacy-respecting metadata. After media deletion, retain only the minimal metadata needed for abuse/safety and legal obligations. Be explicit that "ephemeral" means the media is gone, not that no record ever existed.

Estimations

State assumptions first; the goal is to justify design choices, not produce exact numbers.

Rounded assumptions

  • DAU: ~400M. Users are highly active in messaging — assume ~10–15 snaps sent per user per day → ~5B snaps/day, or ~60K snaps/sec average, ~150K/sec at peak.
  • Recipients per snap: average ~1.5 (mostly 1:1, some small groups). So snap sends fan out to a similar order of per-recipient rows.
  • Media size: photo ~1–2 MB, short video ~5 MB. Ingest ≈ 5B × ~3 MB avg ≈ ~15 PB/day flowing into the blob store — but with short retention (deleted within seconds-to-days), the resident footprint is a small fraction of that.
  • Delete rate: because nearly everything sent is eventually deleted, blob deletions run at roughly the same ~60K/sec order as writes. Delete throughput is a primary constraint.
  • Stories: assume ~15% of DAU post ~1 Story/day → ~60M Stories/day; each viewed by a bounded set of friends. Stories expire at exactly 24h.
  • Friend graph: friendships are mutual and bounded (hundreds, not the hundreds-of-millions of a celebrity follower list). No extreme fan-out problem for direct snaps.

How the numbers affect the design

Signal from the numbers Design decision
~60K snaps/sec, media 1–5 MB, ~15 PB/day ingest Never route bytes through API servers — presigned direct upload to blob store; async processing.
~60K deletes/sec, everything expires Deletion must be TTL-driven and event-driven, never a full-table scan. Use stores with native TTL + object-lifecycle policies.
Short retention, resident footprint ≪ ingest Hot storage tier only; short CDN edge TTLs; no store-forever tier.
Recipients per snap ~1.5; friends bounded No celebrity fan-out problem — a simple per-recipient mailbox works; Stories can be pulled at read time.
Deletion is a hard guarantee Track per-recipient view state explicitly; delete the blob only when all recipients are terminal, with a reaper backstop for the unopened tail.

Caution

Do not model "disappearing" as a client-only behavior — hiding the image in the app while the bytes remain on the server and CDN. That is not deletion: the media is still fetchable, breaching the core privacy promise. Ephemerality must be enforced server-side (blob deletion + short-lived access), with the client view timer as a UX layer on top.

Core entities / data model

Five things are stored: the user/friend graph, the snap (metadata + media pointer, not bytes), the per-recipient delivery/view state (the heart of the lifecycle), the story, and story views.

Entity Key fields
User user_id, username, push_token, created_at
Friend (user_id, friend_id), created_atmutual edge, stored both directions
Snap snap_id, sender_id, media_key (blob pointer), media_type, expiry_policy (view_once | timer_Ns), recipient_count, pending_count, created_at, max_unopened_expires_at
SnapRecipient (snap_id, recipient_id), status (delivered | opened | expired), delivered_at, opened_at, ttl
Story story_id, owner_id, media_key, media_type, created_at, expires_at (= created_at + 24h)
StoryView (story_id, viewer_id), viewed_at — for the "seen by" list; itself TTL'd with the story

The Snap row stores a blob key (media_key), never the bytes — same rule as any media system. It carries two counters that drive deletion: recipient_count (how many it was sent to) and pending_count (how many recipients haven't opened it yet). When pending_count hits zero — everyone opened — the blob is deleted promptly; if some recipients never open, the counter never reaches zero and the TTL/lifecycle backstop (below) deletes the blob at its max age instead, independent of the counter.

The SnapRecipient row is the per-recipient state machine and the most important entity in the design. One row per (snap, recipient). It moves delivered → opened → (deleted), or delivered → expired if never opened before its ttl. This is a high-write, high-delete, short-lived table — the access pattern that dictates the storage choice (deep dive 1). Each row carries a native store TTL (~30 days) as the backstop that guarantees the unopened tail eventually clears even if nothing else fires.

The Story row expires at a fixed expires_at. Stories are a derived, pull-time structure for friends: because the friend set is bounded, we don't fan-out a story into per-friend caches — we assemble a friend's "active stories" list at read time (deep dive 3).

Note

Ephemeral does not mean zero-trace. After the media blob is deleted, you typically retain a minimal metadata record (who sent to whom, when, message id) for a bounded window to support abuse reports, safety, and legal requests. Say this explicitly in the interview — pretending the system keeps literally nothing is both wrong in practice and a red flag to a thoughtful interviewer.

Where each lives. User and Friend live in a sharded relational or wide-column store. Snap metadata and SnapRecipient state live in a store with native per-row TTL and high write/delete throughput — a wide-column store such as Cassandra, or DynamoDB, with native per-row TTL; the hot inbox (a recipient's undelivered snaps) is additionally cached in Redis for fast inbox loads. Media blobs live in an object store (S3-style) with a lifecycle policy as a deletion backstop, served through a CDN. Story/StoryView live in the same TTL-capable store, keyed for per-owner reads.

API design

Six operations carry the system: request an upload URL, send a snap, read the inbox, open a snap, post a story, and read friends' stories.

Step 1 — Request a presigned upload URL

POST /api/snaps/upload-url
Body: { "media_type": "video", "file_size_bytes": 5242880 }
201 Created
Returns: { "upload_url": "https://s3.../...", "media_key": "u123/m789", "expires_at": "..." }

Returns a short-lived presigned URL the client uses to upload the raw media directly to the blob store. The API never touches the bytes.

Step 2 — Send the snap

POST /api/snaps
Body: { "media_key": "u123/m789", "recipients": ["f1","f2"], "expiry_policy": "view_once" }
202 Accepted
Returns: { "snap_id": "...", "status": "sending" }

Creates the Snap row (recipient_count = 2, pending_count = 2), creates a SnapRecipient row per recipient in delivered state, and enqueues push notifications. Returns 202 — delivery is async.

Read the inbox

GET /api/inbox
200 OK
Returns: { "snaps": [ { "snap_id": "...", "sender": "f1", "media_type": "video", "status": "delivered" } ], ... }

Returns pending snap metadata for the caller — never media bytes. Served from the recipient's hot inbox in Redis, backed by the SnapRecipient store.

Open a snap — the key state transition

POST /api/snaps/{snap_id}/open
200 OK
Returns: { "media_url": "https://cdn.../signed?exp=60s", "view_seconds": 0 }

Atomically transitions this recipient's SnapRecipient row delivered → opened (idempotent — re-opening a view-once snap returns nothing), decrements the snap's pending_count, and returns a freshly signed, short-TTL CDN URL (e.g. 60 s) for the media. When pending_count reaches zero, a deletion event is enqueued (deep dive 1). view_seconds conveys the timer for a timed snap; 0 means view-once.

Post a Story

POST /api/stories
Body: { "media_key": "u123/m790", "media_type": "photo" }
201 Created
Returns: { "story_id": "...", "expires_at": "<created_at + 24h>" }

Creates a Story row with a fixed 24-hour expires_at. No fan-out write.

Read friends' active Stories

GET /api/stories/friends
200 OK
Returns: { "friends": [ { "user_id": "f1", "story_ids": [...], "has_unseen": true } ], ... }

Assembles the caller's friends who have unexpired Stories, at read time (deep dive 3). Playing a story issues a short-lived signed media URL per segment and records a StoryView.

High-level architecture

The system has three flows: a media write path (upload → store), a send/receive path (deliver snap metadata, then serve media on open), and a deletion path (expire and delete media and state). Media serving and deletion are the parts that differ most from a normal media app.

1. Send path — upload and deliver

sequenceDiagram
    participant S as Sender
    participant API as API Server
    participant Blob as Blob Store
    participant Meta as Snap Store
    participant PN as Push/Notify
    participant R as Recipient

    S->>API: POST upload-url
    API-->>S: presigned URL + media_key
    S->>Blob: PUT raw media direct
    S->>API: POST snaps recipients, media_key, expiry
    API->>Meta: create Snap + SnapRecipient rows, pending_count=N
    API->>PN: enqueue push to recipients
    API-->>S: 202 sending
    PN->>R: push notification new snap
    R->>API: GET inbox
    API-->>R: pending snap metadata
  1. The sender gets a presigned URL and uploads the raw media directly to the blob store — the API server is never in the byte path.
  2. The sender calls POST /snaps. The API writes the Snap row and one SnapRecipient row per recipient, sets pending_count = N, and enqueues push notifications.
  3. Recipients get a push and load their inbox — metadata only. No media has moved to them yet.

2. Open path — serve media, transition state, trigger deletion

sequenceDiagram
    participant R as Recipient
    participant API as API Server
    participant Meta as Snap Store
    participant DQ as Deletion Queue
    participant CDN as CDN

    R->>API: POST snaps/{id}/open
    API->>Meta: SnapRecipient delivered to opened, decrement pending_count
    API-->>R: signed CDN URL, TTL 60s
    R->>CDN: GET media with signed URL
    CDN-->>R: media bytes
    Note over API,DQ: if pending_count == 0, enqueue delete
    API->>DQ: delete media_key when all viewed
  1. On open, the API atomically flips the per-recipient row to opened and decrements pending_count. This is idempotent — a second open of a view-once snap returns no media.
  2. The API returns a short-lived signed URL; the client fetches bytes from the CDN. The signature TTL (~60 s) means the URL can't be replayed later or shared.
  3. When the last recipient opens (pending_count == 0), the API enqueues a deletion job for the media blob. The client also deletes its local copy after the view timer.

3. Combined architecture

flowchart LR
    Client["Client"] --> GW["API Gateway"]
    GW -->|"upload-url"| API["API Servers"]
    API -.->|"presigned URL"| Client
    Client -->|"direct upload"| Blob[("Blob Store\n(S3-style)")]
    GW -->|"POST /snaps"| API
    API -->|"Snap + SnapRecipient\npending_count=N"| Meta[("Snap Store\n(TTL wide-column)")]
    API -->|"enqueue push"| PN[["Push Queue"]]
    PN --> Mobile["APNS / FCM"]
    GW -->|"GET /inbox"| API
    API -->|"hot inbox"| Redis[("Redis\n(inbox cache)")]
    GW -->|"POST /open"| API
    API -->|"signed URL\nTTL 60s"| Client
    Client -->|"GET media"| CDN["CDN\n(short edge TTL)"]
    CDN -.->|"miss: origin fetch\n(valid signature)"| Blob
    API -->|"all viewed:\nenqueue delete"| DQ[["Deletion Queue"]]
    DQ --> DW["Deletion Workers"]
    DW -->|"delete blob"| Blob
    DW -->|"mark rows deleted"| Meta
    Reaper["TTL Reaper /\nlifecycle policy"] -.->|"expire unopened tail"| Meta
    Reaper -.->|"lifecycle delete"| Blob

The upload, delivery, serving, and deletion paths are decoupled. A burst of sends never blocks opens; deletion runs asynchronously off the open path; and the TTL reaper is an independent backstop that guarantees the unopened tail clears even if a deletion event is ever lost.

Deep dives

1. The ephemeral lifecycle — reliably deleting viewed and expired content

This is the centerpiece and the thing that makes Snapchat Snapchat. The requirement: media must be deleted after all recipients view it, or after a maximum unopened window (say 30 days) if some never open it — and that deletion must be reliable, because it's the product's core promise.

Model each snap as a set of per-recipient state machines. A SnapRecipient row is delivered on send, moves to opened when that recipient opens it, or to expired when its TTL passes unopened. The Snap carries pending_count = the number of recipients who haven't opened it yet. The blob is deleted promptly when pending_count == 0 (all opened); the never-opened tail is handled separately by TTL/lifecycle (mechanism B below), independent of the counter.

The real question is how you drive deletion. Three approaches:

Client-side deletion only — the app hides the media after viewing (never do this alone)

The server keeps the blob; the client just stops showing it after the timer, and maybe tells the server "I viewed it."

  • Pro: trivial to build; instant UX.
  • Con: the media still exists on the server and CDN indefinitely. Anyone with the URL (or a modified client, or a network capture) can re-fetch it. The core privacy guarantee is a lie. It also leaks storage forever — the delete-heavy workload never actually deletes.
  • Verdict: unacceptable as the deletion mechanism. Client deletion is a UX layer on top of server-side deletion, never a substitute. Naming this as the whole answer is the classic failure of this question.
Scheduled scan — a cron job periodically scans the snap table for "all viewed" or "expired" rows and deletes them

A batch job runs every few minutes: SELECT snaps WHERE pending_count = 0 OR max_unopened_expires_at < now(), delete their blobs, delete their rows.

  • Pro: simple mental model; one place owns deletion; naturally idempotent (re-scanning a already-deleted row is a no-op).
  • Con: at ~60K deletes/sec a periodic full/most-of-table scan is enormously expensive and lags reality — media persists for minutes after it should be gone, widening the exposure window on exactly the content you promised to delete promptly. The scan competes with live traffic and becomes a hot, ever-growing job.
  • Verdict: workable only as a low-frequency backstop, not the primary path. You want deletion to fire on the event (last view), with TTL handling the tail — not a table sweep as the main mechanism.
Event-driven deletion on the terminal transition + native TTL for the unopened tail (recommended)

Two cooperating mechanisms, each cheap:

A. Event-driven, on "all viewed." The POST /open handler atomically decrements pending_count. When it reaches zero, it enqueues a deletion job onto a durable Deletion Queue. A pool of deletion workers deletes the blob (and any CDN cached copy — see deep dive 2) and marks the rows deleted. This fires within seconds of the last view — no scan.

open(snap_id, recipient_id):
  if CAS SnapRecipient(snap_id, recipient_id): delivered -> opened   # idempotent
     n = DECR pending_count(snap_id)
     if n == 0:
        enqueue DeletionQueue(media_key)     # delete promptly, all viewed
  return signed_media_url(TTL=60s)

B. Native TTL for the unopened tail. Not every snap is opened by everyone. Give every SnapRecipient row a store-native TTL (~30 days) and the blob an object-store lifecycle policy with the same max age. This guarantees the media and state clear even if a recipient never opens and even if a deletion event is somehow lost — the TTL is the safety net that makes deletion a guarantee rather than a hope.

stateDiagram-v2
    [*] --> delivered: send
    delivered --> opened: recipient opens
    delivered --> expired: TTL passes unopened
    opened --> deleted: last viewer, pending_count == 0
    expired --> deleted: TTL reaper / lifecycle
    deleted --> [*]

Why both. The event path gives prompt deletion for the common case (everyone views quickly). The TTL path gives guaranteed deletion for the tail and covers lost events, worker outages, and never-opened snaps. Deletion must be idempotent — deleting an already-deleted blob is a no-op — so a redelivered queue message or an overlap between the event and TTL paths is harmless.

  • Pro: deletion fires within seconds for viewed content; the TTL backstop makes "it will be deleted" a true guarantee, not dependent on any single component staying up; both paths are O(1) per snap, no scans.
  • Con: two mechanisms to reason about; you must keep them idempotent and make pending_count updates atomic (a CAS/conditional update, or a per-recipient tombstone you count) so a double-open can't drive the counter negative or double-fire deletion.
  • Verdict: the standard shape for reliable ephemeral deletion — act on the event for promptness, lean on native TTL/lifecycle for the guarantee.

Warning

Make the open transition and pending_count update atomic and idempotent. If two opens (a retry, a double-tap) both decrement, or if a non-idempotent open lets a view-once snap be re-served, you either delete too early or leak the media. Use a conditional state transition (compare-and-set delivered → opened) that only decrements on the first successful transition.

What "deleted" really means. Deleting the blob removes the viewable media. You will typically retain a slim metadata record (sender, recipients, timestamps, moderation flags) for a bounded window for safety/legal reasons — and you should say so. If the product requires stronger guarantees, end-to-end encryption plus crypto-shredding (discard the per-message key so the ciphertext is unrecoverable) is the stronger form — mention it as the direction for a privacy-maximal design.

2. Media delivery for short-lived content — the inverse CDN strategy

Snap media still needs a CDN (you're serving 1–5 MB blobs to users worldwide with low latency), but the caching strategy is the opposite of a store-forever media system. Instagram serves immutable content-addressed URLs with max-age=1 year, immutable. Snapchat must serve content that will be deleted minutes later — you cannot let a warm CDN edge keep serving a snap after it's been deleted.

Short-lived signed URLs are the primary access control. The POST /open response returns a CDN URL signed with a short expiry (e.g. 60 s) — a signed URL the CDN validates before serving. After 60 s the signature is invalid, so the URL can't be replayed, shared, or reused — independent of whether the blob has been physically deleted yet. This closes the gap between "user viewed it" and "blob physically deleted."

Short edge TTLs and purge-on-delete. Cache media at the edge only briefly (short max-age), so an edge won't hold a copy long after deletion. When a deletion job runs, it also issues a CDN purge for that key as belt-and-suspenders. The combination — short signature TTL + short edge TTL + purge — means a deleted snap is unreachable through the CDN within seconds.

flowchart LR
    Open["POST /open"] -->|"sign URL\nexp=60s"| Client["Client"]
    Client -->|"GET media\n(valid signature)"| Edge["CDN Edge\n(short TTL)"]
    Edge -.->|"miss: origin fetch\nif signature valid"| Blob[("Blob Store")]
    Del["Deletion Worker"] -->|"delete object"| Blob
    Del -->|"purge key"| Edge

Caution

Do not apply long/immutable CDN caching to ephemeral media. max-age=1 year, immutable is correct for permanent content-addressed media but catastrophic here — a deleted snap would keep being served from edge caches for as long as the TTL, directly violating the deletion guarantee. Short signature TTL + short edge TTL + purge-on-delete is the pattern.

Upload/processing pipeline. Same presigned-upload shape as any media system: client uploads raw bytes directly to the blob store; if transcoding/thumbnailing is needed it runs async off the send path. The difference is retention — these blobs live briefly, so a single hot storage tier is fine and there's no cold-tiering step. Processing must be fast enough that a snap is deliverable within a second or two.

3. Delivery to offline recipients, and Stories fan-out and expiry

The mailbox / hold-until-delivered model. A snap sent to an offline friend can't be pushed to their device immediately. Model each recipient's inbox as a durable mailbox: the SnapRecipient row (backed by the TTL store, hot copy in Redis) is the mailbox entry. When the recipient comes online and calls GET /inbox, they get the pending snaps. A push notification is a best-effort nudge — delivery correctness rests on the durable mailbox, not on the push landing.

flowchart LR
    Send["Send snap"] -->|"write SnapRecipient\n(delivered)"| MB[("Recipient Mailbox\n(TTL store + Redis)")]
    Send -.->|"best-effort push"| PN["APNS / FCM"]
    Offline["Recipient offline"] -->|"comes online\nGET /inbox"| MB
    MB -->|"pending snaps"| Offline
    TTL["Unopened TTL / reaper"] -.->|"expire after 30d"| MB

The unopened-TTL rule (deep dive 1) covers the recipient who never comes back: after ~30 days the mailbox entry expires and the blob is lifecycle-deleted. Delivery is thus "hold until delivered, but not forever."

Stories — pull at read time, expire by TTL. A Story is posted once and viewed by the owner's friends within 24 hours. Because the friend set is bounded (hundreds), there's no celebrity-scale fan-out problem — so we don't push a story into per-friend caches. Instead, assemble the reader's "active stories" at read time:

  1. Read the caller's friend list (bounded).
  2. For each friend, look up their unexpired Stories (expires_at > now()), via a per-owner index — a small, bounded query.
  3. Return the friends who have active Stories, with an unseen flag derived from StoryView.

Expiry is expires_at-driven: a Story simply stops being returned once expires_at passes, and its row + media are cleared by the same native TTL + object lifecycle mechanism as snaps — no scan, no per-follower cache to scrub. The StoryView rows (the "seen by" list) are TTL'd to expire alongside the story.

Note

Stories are where Snapchat and Instagram converge — both are 24-hour ephemeral. The reason we can pull-at-read here (and Instagram leans on precomputed feeds) is the audience: a Story's viewers are your bounded friend set, so a read-time assembly of "friends with active stories" is cheap. If Stories had to fan out to millions of followers, you'd reintroduce the hybrid fan-out problem — worth naming if the interviewer pushes on a public-figure Story.

Screenshot detection. Users can screenshot a snap; you cannot truly prevent it (the pixels are on their screen). The realistic design is best-effort client detection (OS screenshot signals) reported to the server, which notifies the sender ("Alice took a screenshot"). Be honest in the interview: this is a deterrent and a notification feature, not a security guarantee.

Tradeoffs & bottlenecks

  • Event-driven deletion vs scheduled scan. Event-on-last-view gives prompt deletion but needs atomic, idempotent counters; a scan is simpler but laggy and expensive at 60K/sec. The production answer is event-driven for promptness plus native TTL/lifecycle as the guaranteeing backstop.
  • Signature TTL vs physical deletion. A short-lived signed URL revokes access the moment it expires, decoupling "access revoked" (fast, ~60 s) from "blob physically deleted" (slower, async). They work together: the short signature is what actually guards a not-yet-deleted blob, while short edge TTLs plus purge-on-delete stop a still-signed request from being served a deleted object out of a warm edge. Keep both short.
  • Short vs long CDN caching. Short edge TTLs + purge protect the deletion guarantee but reduce cache hit ratio and raise origin load — acceptable because content is viewed a small bounded number of times anyway, so cache reuse was never high.
  • Delete-heavy storage. The store must sustain deletes at write scale. Native TTL (Cassandra/DynamoDB) and object-lifecycle policies push expiry into the storage layer instead of application-driven deletes; the tradeoff is coarser timing (TTL granularity) which the event path compensates for.
  • Prompt deletion vs metadata retention. The media is deleted promptly, but minimal metadata is retained for safety/legal for a bounded window — an explicit, defensible tradeoff rather than a claim of zero retention.
  • Push reliability vs mailbox durability. Push is best-effort; correctness lives in the durable mailbox so an offline or push-dropped recipient still receives the snap on next inbox load.
  • Pull-at-read Stories vs precomputed. Pull is simple and correct for bounded friend sets and avoids cache-scrub on expiry; it costs a small read-time assembly per Story load. Precomputation would only pay off for very-high-audience Stories.

Extensions if asked

  • End-to-end encryption + crypto-shredding. Encrypt each snap with a per-message key held only by sender and recipients; the server stores ciphertext. "Deletion" becomes discarding the key (crypto-shredding), making the stored blob permanently unreadable — the strongest form of the guarantee.
  • Group snaps and chat. Larger recipient sets and persistent (non-ephemeral, or save-able) chat messages change the lifecycle — introduce a "saved" flag that exempts a message from expiry, and reason about per-participant save.
  • Replay / "show once more." A bounded allowance to re-open recently-viewed snaps requires deferring deletion by a short grace window before the terminal transition — a small tweak to the state machine.
  • Public / creator Stories (Spotlight). A public figure's Story viewed by millions reintroduces fan-out-scale reads; you'd move from pull-at-read toward precomputed/cached viewer structures — the Instagram hybrid discussion applies.
  • Abuse and safety. Even ephemeral media may need scanning (CSAM/hash matching) before or at send; retained metadata and reported-content snapshots support moderation without keeping all media.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Server-side deletion as the mechanism, with client hiding as a UX layer on top — not the other way around. This is the single biggest signal.
  • The per-recipient state machine and a pending_count that triggers deletion when the last recipient views — plus a native TTL/lifecycle backstop for the unopened tail and lost events, so deletion is a guarantee.
  • Short-lived signed URLs + short CDN TTLs for media, and recognizing this is the inverse of an immutable-forever media CDN strategy.
  • Framing the workload as write- and delete-heavy and treating delete throughput as a first-class constraint.
  • Presigned direct upload so API servers never proxy media bytes.
  • A durable mailbox for offline delivery, with push as a best-effort nudge.
  • Honesty about limits — retained metadata, screenshot detection being best-effort, and E2EE/crypto-shredding as the stronger privacy direction.

Before you finish, do a quick mistake check:

  • Did you enforce disappearance server-side (blob deletion + short-lived access), not just by hiding it in the client?
  • Did you track per-recipient view state and delete the blob when the last recipient views, with a TTL backstop for the unopened tail?
  • Did you make the open transition and counter update atomic and idempotent so you never delete early or leak media?
  • Did you use short signature TTLs and short CDN edge TTLs (+ purge on delete) — not immutable-forever caching?
  • Did you use presigned direct upload, keeping API servers out of the media byte path?
  • Did you handle offline recipients with a durable mailbox rather than relying on push?
  • Did you make Stories pull-at-read with expires_at/TTL expiry, and note when a high-audience Story would change that?
  • Were you explicit that some metadata is retained, and did you name E2EE/crypto-shredding as the stronger guarantee?

Practice this live

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

Start the interview →