WhatsApp Status looks like Instagram Stories with a different logo, and if you design it like Stories you will get most of it right and miss the one thing that makes it hard. Stories is a public-ish follow graph where the server can read everything. Status is end-to-end encrypted over a bidirectional phone-contacts graph with per-contact privacy rules, and the server is never allowed to see the pixels. Those two facts - the server cannot read the media, and “who can see this” is a function of two address books agreeing - reshape the entire fan-out, and that is where the interview lives.
Three things define this system. First, ephemerality: a status exists for exactly 24 hours, then it is gone, so the whole data model is a TTL, not durable storage. Second, end-to-end encryption at fan-out scale: the media is encrypted on the poster’s phone and the server stores only ciphertext it cannot decrypt, so every design choice about delivery has to carry a key-distribution problem alongside it. Third, the visibility graph is contacts, not followers: you see my status only if we have each other in our address books and I have not excluded you, which is a mutual, privacy-filtered computation, not a public follow edge.
The decision that colors everything: the media is one encrypted blob; the expensive, interesting part is distributing the key to the right contacts and nobody else. Get that wrong and you either leak status to people who should not see it, or you re-encrypt a 1 MB video once per contact and blow up your storage by 100x. Let me build it properly.
Functional Requirements (FR)
In scope:
- Post a status. A user uploads a photo or short video (or a text/background card). It becomes viewable by their eligible contacts for exactly 24 hours, then auto-deletes. The media is end-to-end encrypted; the server never sees plaintext.
- View a contact’s status. When a user opens the Status tab, they see a list of contacts who have active (unexpired) status, unseen ones first. Tapping plays that contact’s statuses oldest-to-newest, then advances to the next contact.
- Privacy control. The poster chooses an audience: My contacts, My contacts except…, or Only share with…. A contact outside the audience must never receive the status or its key.
- Seen / unseen state (per viewer). The Status list reflects what this viewer has and has not watched; unseen contacts sort first.
- Viewer list / read receipts (for the author). The author sees who viewed each status (if the viewer has read receipts on), newest first.
- Auto-expiry. After 24 hours a status vanishes from every list, becomes unplayable, and its bytes are reclaimed.
Explicitly out of scope, said out loud so I control the scope:
- The Signal double-ratchet / X3DH key-exchange internals. I assume every pair of contacts already has a working pairwise encrypted session (WhatsApp sets this up for messaging). I design the status layer on top of it - specifically the sender-key distribution - and treat the pairwise session as a primitive.
- Status replies and reactions. A reply is just a normal E2E message triggered from a status; I note the hook but do not re-design messaging.
- Voice/video, the main chat system, payments. Separate systems. I reuse the messaging transport (persistent connections) as a primitive for key delivery and receipts.
- Video transcoding internals. Status clips are short; the client encrypts an already-compressed rendition. The upload/CDN path shape is standard; the twist is only that the bytes are ciphertext.
- Content moderation / ranking. E2E means the server cannot scan content anyway; moderation is report-driven, not proactive. No ML ranking of the status list beyond unseen-first + recency.
The single decision that drives the architecture: this is an ephemeral, read-heavy, end-to-end-encrypted broadcast over a mutual-contacts graph. The dominant operation is “deliver a tiny encrypted key and a media pointer to each eligible contact, let them pull the one shared ciphertext blob, and forget all of it in 24 hours.” The media bytes are a solved CDN problem. The key fan-out and the privacy-correct recipient set are the whole game.
Non-Functional Requirements (NFR)
- Scale: ~1B status users. ~150M statuses posted/day. ~5B status views/day. Average user has ~100-150 contacts eligible to see a status. Each status lives 24 hours, so the entire active corpus is only ever ~1 day of data.
- Latency: Status list load p99 under 200ms (metadata and keys only; media streams from CDN). Media playback start under 150ms from the edge. Marking a status seen is fire-and-forget and must not block playback.
- Availability: 99.99% on the read paths (list + playback + key fetch). Posting can tolerate marginally lower availability (client retries).
- Consistency: eventual almost everywhere. Two hard rules: privacy is never eventually consistent in the wrong direction - an excluded contact must never receive the key even transiently (fail closed); and read-your-own-seen-state within a session. A freshly posted status reaching contacts within a few seconds is fine.
- Durability: deliberately low. Status is ephemeral by design; losing an in-flight seen-marker is acceptable. The encrypted media must survive its 24h window, but after expiry we want it gone. This is the rare system where low durability is a feature and deletion is a product promise.
- Security: end-to-end encrypted. The server stores ciphertext and encrypted key material only; a full breach of the status backend must not reveal a single status image. This is a hard constraint, not a nice-to-have.
- Freshness / expiry: a status must become unplayable within seconds of its 24h mark. No zombie status, because a lingering status is both a correctness bug and a privacy breach.
Back-of-the-Envelope Estimation (BoE)
Real numbers with the arithmetic, because the read/write asymmetry and the encrypted fan-out dictate the design.
Writes (posting status):
150M statuses / day
= 150,000,000 / 86,400 sec
≈ 1,740 posts/sec average
Peak (3x) ≈ 5,200 posts/sec
Posting is low-QPS. The system is not write-heavy on the post path itself - but each post triggers a fan-out, and that is where volume hides.
Key / notification fan-out (the real write load). Each post must reach every eligible contact. With ~100 eligible contacts on average:
150M posts * 100 contacts = 15B fan-out deliveries/day
= 15,000,000,000 / 86,400 ≈ 174,000 deliveries/sec average
Peak (3x) ≈ 520,000 deliveries/sec
So the fan-out is ~100x the post rate. The entire design question is: what do we fan out? If it is a 1 MB encrypted blob, we are dead. If it is a ~100-byte “status X is available” notification plus an occasional small encrypted key, it is cheap. (Spoiler: the latter.)
Reads (viewing status):
5B status views/day = 5B / 86,400 ≈ 58,000 views/sec average
Peak (3x) ≈ 175,000 views/sec
Each opened status also fires one seen-marker:
~5B seen-markers/day, same rate.
Active corpus size - the number that shapes everything. Status lives 24h, so at any instant we hold only ~1 day:
150M active statuses at any time.
Metadata per status: status_id(8) + author_id(8) + blob_key(~40)
+ type/len/ts/expiry(~40) ≈ ~100 bytes
150M * 100 B ≈ 15 GB of status metadata, total, ever.
Fifteen gigabytes of metadata, flat forever. The opposite of a durable media service’s petabyte curve. Ephemerality shrinks the corpus to nothing; we shard for QPS, not size.
Encrypted media storage (the easy part - one blob per status, NOT per recipient):
Avg status media (encrypted, compressed image or short clip) ≈ 1 MB.
ONE ciphertext per status (shared by all viewers), so:
150M * 1 MB ≈ 150 TB/day of new ciphertext...
...all expires in ~24h, so steady-state footprint ≈ 150 TB total, flat.
150 TB, flat. Note the trap we are avoiding: if we naively encrypted the blob once per recipient, it would be 150M * 100 * 1 MB = 15 PB/day. A 100x blow-up. The single-blob-plus-key-fan-out model is what keeps storage sane under E2E. More on this in the deep dive.
Seen-state size:
~5B (viewer, status) seen edges/day, each expiring with the status (24h).
Steady state ≈ 5B edges. Per edge ≈ 50 B with overhead.
5B * 50 B ≈ 250 GB of transient, self-cleaning seen-state.
A cache-cluster number, not a database number, and it TTLs itself.
Bandwidth (egress) - the CDN’s job:
58K views/sec * 1 MB avg ≈ 58 GB/sec average
Peak ≈ 175K/sec * 1 MB ≈ 175 GB/sec
Tens to low-hundreds of GB/sec of encrypted bytes, served from the CDN edge. Real but a solved problem; the bytes being ciphertext changes nothing about caching since the media is immutable and content-addressed.
Key-material storage. The encrypted sender keys (one per (sender, recipient) session, reused across that sender’s statuses until rotation) plus per-status message-key references:
~1B users, each posting to ~100 contacts => ~100B potential sender-key edges,
but only senders with active contacts matter, and keys are reused across statuses.
Per encrypted sender-key record ≈ 100-200 B.
Active distributed key set at any time is in the low TB range, TTL'd / rotated.
Small relative to media, and it is the part the server cannot read. Good.
High-Level Design (HLD)
Four paths. Unlike a public feed, the hot, hard path is key fan-out + encrypted status availability, not media. The post path is low-QPS but triggers encryption and fan-out. The media path is high-bandwidth but a solved, content-addressed CDN problem. The list/seen path mirrors any stories system. The key insight in the diagram: plaintext and keys exist only on devices; the server tier moves ciphertext and encrypted key blobs it cannot open.
+----------------------------+
| Client (device) |
| - holds private keys |
| - encrypts/decrypts status |
| - computes eligible contacts|
+-------------+--------------+
| (ciphertext + encrypted keys only)
+-------------v--------------+
| Load Balancer |
+--+--------+---------+-------+
POST path | | FAN-OUT | | LIST / VIEW path
+----------------------v-+ +----v--------+--+ +-v---------------------+
| Status Service | | Fan-out Service| | List/View Service |
| (presign, confirm meta) | | (per-recipient | | (eligible active |
+--+------------------+---+ | key+notif push)| | authors, seen-state) |
| | +--+-------------+-+ +--+------------------+--+
1.presign 3.confirm| | enqueue | read list/seen |
| (meta) | | deliveries | |
+---v-------+ +------v-----+ +--v-------------+ +--v---------------+ |
| Object | | Status Meta| | Status Inbox | | Seen-State Store | |
| Store | | (KV, TTL | | (per recipient:| | (KV: viewer:auth | |
| (cipher, | | 24h) | | [status_id + | | -> hwm, TTL24h) | |
| 25h life)| +-----+------+ | enc_key_ref]) | +--------+---------+ |
+----+------+ | | KV, TTL 24h | | |
| | emit +--+-------------+ | |
| encrypted | author | pushed via | |
| GETs | index-add | messaging transport | |
+----v------+ +------v-----+ +--v-------------+ +-------v---------+ |
| CDN | | Key Store | | Persistent Conn| | Viewer-List | |
| (edge, | | (enc sender | | / Push (online | | Store (status-> | |
| immutable | | keys, TTL/ | | delivery) | | [viewer], cap, | |
| cipher) | | rotation) | +----------------+ | TTL 24h) | |
+-----------+ +-------------+ +-----------------+ |
^ |
| clients pull ciphertext, decrypt locally with their derived key -----+
Post flow (upload an encrypted status):
- The poster’s client picks the audience (privacy list) and resolves, from its local address book plus server-confirmed mutual-contact bits, the exact set of eligible recipient IDs. Privacy is decided on the device - the server is never the authority on “who may see this.”
- Client calls
POST /status/upload-url. Status Service assigns astatus_id(Snowflake, time-sortable), returns a presigned PUT URL into the object store. - Client generates a random symmetric media key
K, encrypts the media withK, and uploads the single ciphertext blob directly to the object store (bytes never touch the app tier, and they are opaque to it). The blob is content-addressed by a hash so identical re-uploads dedupe. - For each eligible recipient, the client encrypts
K(or, better, a sender-key ratchet that derivesK, see deep dive) using the existing pairwise Signal session with that recipient. This produces one small encrypted-key blob per recipient. The client ships the list[(recipient_id, enc_key_blob)]plusstatus_idand the media pointer to the Fan-out Service inPOST /status/{id}/confirm. - Status Service writes
status_meta(KV, 24h TTL) and appendsstatus_idto the author’s index. The Fan-out Service writes into each recipient’s Status Inbox a tiny record{status_id, author_id, enc_key_ref, media_ptr, expires_at}(KV, 24h TTL) and pushes a lightweight “new status” notification down the recipient’s persistent connection if online.
List read flow (open the Status tab):
- Client
GET /status/list. Service reads the viewer’s Status Inbox (the statuses fanned out to this viewer), filters to unexpired, and joins against the viewer’s seen-state high-water marks. - Sort: contacts with unseen status first (by recency), then fully-seen. Return author entries with
unseenflag,status_ids, encrypted-key references, and CDN media pointers. - To play, the client fetches the ciphertext from the CDN and the
enc_key_reffrom the Key Store, decryptsKlocally with its pairwise session, decrypts the media. The server never heldKin the clear.
Watch + seen flow:
- Client pulls ciphertext from CDN edge (>95% hit, immutable), decrypts locally.
- Client fires
POST /status/{id}/seen(fire-and-forget). Service advances the(viewer, author)seen high-water mark (24h TTL) and, if the viewer allows read receipts, appendsviewer_idto the author’s capped viewer list - delivered to the author as an encrypted receipt over the messaging transport.
Expiry flow (the defining behavior):
- Every store - status meta, author index, each recipient’s inbox entry, seen-state, viewer list, key store, and the object blob - is written with a 24h TTL (media 25h for a buffer) at creation. Expiry is passive; entries vanish on their own timer. No sweeper on the critical path.
- Reads apply a
now < expires_atfilter so a status is logically gone the instant it expires, even before physical eviction. No zombie status is ever served or ever leaks past its promised lifetime.
The whole point: the server is a dumb, self-cleaning relay of ciphertext and encrypted keys. All the trust, privacy, and plaintext live on the devices, and all the storage expires itself in a day.
Component Deep Dive
The hard parts, naive-first then evolved: (1) end-to-end encryption at fan-out scale - the sender-key trick; (2) the contacts graph and privacy-correct recipient computation; (3) status availability fan-out - on write vs on read under E2E; (4) auto-expiry and the 5B/day seen-writes.
1. End-to-end encryption at fan-out scale
This is the part that is genuinely different from Stories, so it gets the most room. The constraint: the server must never see plaintext, yet potentially 100+ contacts must each be able to decrypt the same status.
Naive approach: encrypt the media separately for each recipient
The obvious first cut: for each of the poster’s N contacts, take the pairwise encrypted session you already have with that contact and encrypt the whole status media with it. Upload N ciphertexts, one per recipient, each only that recipient can open.
Where it breaks, hard:
- Storage and upload blow up by the fan-out factor. A 1 MB video to 100 contacts = 100 MB uploaded and stored per status. At 150M statuses/day that is
150M * 100 * 1 MB = 15 PB/day. Even expiring in 24h, that is a petabyte-scale steady state and a 100x bandwidth tax on the poster’s uplink. The poster’s phone would have to encrypt and upload the clip 100 times. - Posting latency is O(N) in the recipient count. Encrypting a multi-megabyte blob 100 times on a phone is seconds of CPU and a huge upload. A user with 500 contacts waits forever.
- No dedupe possible. Because each ciphertext is keyed to a different session, the 100 copies are byte-different; the CDN and object store cannot dedupe them.
The root error: encrypting the bulk payload per recipient. Asymmetric/pairwise encryption is for small secrets, not megabyte media.
Evolved approach: one symmetric blob, fan out only the key (sender keys)
Encrypt the media once with a fresh random symmetric key K (AES-GCM). Upload one ciphertext blob. Then the only thing you fan out per recipient is K itself (tens of bytes), wrapped in each recipient’s pairwise session. The media stays a single shared blob; only the small key is replicated N times.
on post(media, recipients):
K = random_symmetric_key() # per-status (or derived, below)
ct = AES_GCM_encrypt(media, K) # ONE blob
blob_key = sha256(ct) # content-addressed, dedupes
put(object_store, blob_key, ct) # single upload
for r in recipients: # tiny per-recipient work
enc_K[r] = pairwise_session(me, r).encrypt(K) # ~tens of bytes
confirm(status_id, blob_key, [(r, enc_K[r]) for r in recipients])
Now storage is 150M * 1 MB = 150 TB/day, not 15 PB. Per-recipient cost is a tiny key encryption, so posting is fast regardless of audience size.
The further optimization - sender keys, so you do not re-wrap per status. If Alice posts five statuses a day to the same 100 contacts, wrapping a fresh K for all 100 on every post is 500 key-encryptions/day of redundant pairwise work. WhatsApp’s group/status model uses a Sender Key: Alice creates one symmetric sender-key chain for her “status session,” distributes that once to each contact (encrypted in the pairwise session), and then every subsequent status’s media key is derived from the ratcheting chain - so recipients who already hold the sender key can derive each new status’s K with no new per-recipient server round trip.
Sender Key model:
Alice creates SenderKey_A (a symmetric chain key) once.
Distribute SenderKey_A to each contact ONCE (pairwise-encrypted). [O(contacts), rare]
Per status: K_i = KDF(ratchet(SenderKey_A, i)) # derived, no re-distribution
Media encrypted with K_i; recipients derive K_i locally from SenderKey_A.
=> per-status fan-out is just "status i exists", not a key per recipient.
When must the sender key rotate (and be redistributed)? Whenever the audience shrinks - a contact is removed, blocked, or excluded via a privacy change - the old sender key must be rotated so the removed contact cannot decrypt future statuses. This is exactly the “remove a member from a group” case in Signal: generate a new sender key, distribute it to the remaining contacts only. Adding a contact just means distributing the current sender key to the newcomer. So:
| Event | Cost | Why |
|---|---|---|
| Post status (same audience) | Derive K_i, one blob, broadcast “status i” notif | Recipients derive the key; no re-wrap |
| Add a contact to audience | Wrap current sender key for 1 new recipient | Newcomer can decrypt going forward |
| Remove / exclude a contact | Rotate sender key, re-wrap for remaining N-1 | Fail closed: removed contact cannot derive future keys |
The removed contact keeps whatever they already decrypted (you cannot unsee), but gets nothing new - which is the correct, honest guarantee. Bulk payload encrypted once symmetrically; only the small sender key crosses the network per recipient, and only when membership changes. That is the entire trick that makes E2E broadcast affordable.
2. The contacts graph and privacy-correct recipient set
Who can see my status? Not “my followers” - there is no follow graph. It is mutual contacts, minus my privacy exclusions, intersected with their block lists. Computing that set correctly, and failing closed, is its own hard problem.
Naive approach: server-side global contact table, computed at post time
Store every user’s address book server-side as an edge table, and on each post run a query: find all users who have me in their contacts and whom I have in mine, subtract my exclusion list, and fan out to the result.
Where it breaks:
- It centralizes the address book in plaintext. Now the server holds the global social graph of everyone’s phone contacts - a privacy catastrophe and exactly the kind of data an E2E product is supposed to not concentrate. One breach leaks who-knows-whom for a billion people.
- Mutuality is expensive to compute server-side at post time. “Users who have me AND whom I have” is a bidirectional intersection per post. At 1,740 posts/sec, each scanning contact edges both directions, this is a constant graph-join firehose.
- Privacy races. If I exclude someone a second before posting, a server-side eventual-consistent exclusion list can still leak the status to them. Privacy must fail closed, and a lagging replica of an exclusion list does not.
Evolved approach: client computes the recipient set; server fans out only to the given list
The poster’s device already holds the authoritative address book and the privacy settings. Let the client compute the eligible recipient set and hand the server an explicit list. The server never needs the global contact graph; it only needs the small per-post list the client provides.
on compose(audience_setting):
mine = local_address_book() # on device
# server confirms mutuality cheaply without revealing graphs:
mutual = server.check_mutual(hashed(mine)) # returns subset that also has me
eligible = apply_privacy(mutual, audience_setting) # except-list / only-list
eligible = eligible - blocked_by_them # honored via their session state
# fan out keys + notif to `eligible` only
Three things make this correct and private:
- Mutuality check without a central graph. The client sends hashed contact identifiers; the server returns which of them are also WhatsApp users who have the poster in their book (a bit it can answer from per-user “who added me” state) without the server ever materializing a readable global edge list. The expensive bidirectional intersection becomes a cheap membership check the poster drives.
- Privacy is enforced on the device and cryptographically, not just by a server filter. Because the key is only wrapped for
eligible, an excluded contact cannot decrypt even if a buggy fan-out accidentally delivered the ciphertext. Privacy is enforced by the absence of a key, which is unforgeable - not by a server-side ACL that could be bypassed. This is why “fail closed” is real here: no key, no plaintext, full stop. - Exclusion = sender-key rotation (from deep dive 1). Removing someone is not a server ACL flip; it rotates the sender key so the excluded contact is cryptographically locked out of future statuses. No race, no lagging replica can leak.
The server’s job collapses to: accept an explicit recipient list from a trusted device, fan out tiny records to those inboxes, and never be the authority on visibility. The graph stays on the edges.
3. Status availability fan-out - on write vs on read
Once the client has the recipient list and keys, how does a status reach each contact’s Status tab? This is the classic fan-out fork, re-decided under E2E.
Naive approach: fan-out on read, like a public follow feed
Keep an author -> [active status_ids] index, and on list-load have the viewer gather all of their contacts’ entries and filter. No per-post write amplification.
Where it strains here specifically:
- The key is per-(sender, recipient), so something must be delivered per recipient anyway. Unlike public Stories where any viewer can read any public author’s index, here a viewer can only decrypt a status if the sender wrapped a key for them. That per-recipient key handoff is inherently a fan-out-on-write act. You cannot avoid touching each recipient at least once (to deliver the wrapped key or confirm they hold the sender key).
- Pure read-side gather does not know who is eligible. A viewer scanning “my contacts’ active status” would surface statuses they have no key for. You would leak existence (metadata) even if not content, and waste reads.
Evolved approach: fan-out on write of tiny records to per-recipient inboxes
Because a key must reach each recipient regardless, embrace it: on post, write a tiny record into each eligible recipient’s Status Inbox (recipient -> [{status_id, author_id, enc_key_ref, media_ptr}], 24h TTL) and push a lightweight notification over the recipient’s persistent connection if online. List-load is then a single read of the viewer’s own inbox - fast, and it only ever contains statuses the viewer can actually decrypt.
Why fan-out on write is correct here, the opposite of a huge public feed:
- What we fan out is tiny. Not media (one shared blob on the CDN), not even always a key (sender keys are reused) - just a ~100-byte “status X from author Y is available, here is the pointer and key ref.” 15B of those/day is a metadata firehose, not a media one, and inboxes are TTL’d KV writes.
- It bounds the read path and respects privacy by construction. The viewer’s inbox contains exactly the statuses meant for them. No read-time eligibility computation, no leakage of statuses they lack keys for.
- The celebrity problem is bounded by the contacts model. There is no 100M-follower account in Status - visibility is capped at your mutual contacts (realistically thousands at the extreme, not millions, since it is your phone book). The fan-out is
O(contacts), and contacts are bounded in a way public followers are not. This is the quiet gift of the contacts graph: it caps fan-out for free.
| Strategy | Post cost | List-read cost | Privacy under E2E | Verdict |
|---|---|---|---|---|
| Fan-out on read (public-feed style) | O(1) index append | O(contacts) gather + eligibility calc | Leaks metadata; no place for per-recipient key | Wrong shape for E2E |
| Per-recipient media encryption | O(contacts) * media | O(1) read own copy | Private but 100x storage | Storage disaster |
| Fan-out on write of tiny key+notif records | O(contacts) tiny writes | O(1) read own inbox | Key-gated; private by construction | The answer |
Because the contacts graph caps fan-out and the fanned-out unit is tiny, fan-out on write is both affordable and the only model that cleanly carries the per-recipient key. Media stays a single CDN blob everyone shares.
4. Auto-expiry and the 5B/day seen-writes
Two coupled ephemeral-scale problems: deleting everything at 24h without a delete storm, and absorbing 5B seen-markers/day.
Naive approach: a cron that scans and deletes expired status; a SQL row per seen event
A scheduler sweeps every few minutes deleting status older than 24h across meta, inboxes, keys, seen-state, viewer lists, and blobs. Seen events are INSERTs into a status_views(viewer, status, ts) table, joined on list-load.
Where it breaks:
- Delete storm. 150M statuses + 15B inbox records + 5B seen edges expiring daily means the cron issues hundreds of thousands of deletes/sec, competing with live traffic on the same shards, across six stores. The GC becomes a second workload as big as production.
- Zombie-status privacy bug. Run the cron rarely to save load and status lingers past 24h - a broken promise and a privacy breach, since “it disappears” is the product.
- Multi-store inconsistency. A status lives in six places; sequential cron deletes can leave it gone from meta but alive in someone’s inbox.
- Seen-writes destroy the DB. 5B tiny inserts/day (~58K/sec, ~175K peak) into one table, all disposable in 24h, is a firehose the index maintenance and vacuum cannot survive - durable-write cost for throwaway data.
Evolved approach: native TTL at write + read-time filter; async idempotent high-water-mark seen state
Expiry: write every piece of status data with a native 24h TTL (blob 25h for a playback-boundary buffer) at creation. The datastore reclaims it lazily and for free - Redis key expiry, Cassandra/DynamoDB TTL, S3 lifecycle. A read-time now < expires_at filter closes the lazy-eviction gap so a status is logically gone at its deadline. Independent TTLs mean no cross-store delete transaction to botch; all six stores converge to empty within the same window.
confirm/fan-out writes, all EX=86400 (blob EX=90000):
status_meta.set(status_id, {...})
author_index.append(author_id, status_id)
inbox.append(recipient_id, {status_id, enc_key_ref, media_ptr}) # per recipient
# blob lifecycle rule deletes ciphertext at 25h
Seen-state: store a per-(viewer, author) high-water mark - the newest status_id this viewer has seen from this author - in a TTL’d KV store sharded by viewer_id. Because status_id is Snowflake (monotonic), “does author A have unseen status for viewer V?” is a single integer compare: newest_active(A) > hwm(V, A). Marking seen is a max() update: idempotent, so at-least-once client delivery is safe.
on view(status_id, author_id, viewer_id): # fire-and-forget, async queue
worker (batched):
seen_hwm.max("seen:{viewer}:{author}", status_id, EX=86400) # idempotent
if viewer_allows_receipts:
viewer_list.append_capped(status_id, viewer_id, cap=2000, EX=86400)
# author receives the viewer as an E2E receipt over messaging transport
The 5B/day is absorbed by making seen-writes async, idempotent, sharded by viewer_id, and deliberately low-durability - a lost marker just re-shows one status once, which the NFRs permit. No synchronous durable write, no cron, no delete storm. Auto-delete becomes a property the datastore enforces for us.
API Design & Data Schema
API
POST /api/v1/status/upload-url
Response 200:
{ "status_id": "1998244112233000",
"upload_url": "https://blob.cdn.example/raw/1998...?X-Signature=...", // presigned PUT
"expires_in": 300 }
PUT <upload_url> # client uploads ENCRYPTED media directly to blob store
Body: <ciphertext> # server-opaque; never passes through app tier in clear
POST /api/v1/status/{id}/confirm
Body: {
"type": "video", "duration_ms": 12000, "blob_key": "sha256:...",
"audience": "contacts_except",
"recipients": [ { "uid": "77", "enc_key_ref": "k_77" }, ... ] // client-computed, key-wrapped
}
Response 202:
{ "status_id": "1998244112233000", "expires_at": "2026-11-12T09:12:00Z" } // created_at + 24h
Errors: 400 blob missing/invalid | 401 not auth | 409 already confirmed
GET /api/v1/status/list
Response 200:
{ "list": [
{ "author": { "id": "42" },
"unseen": true,
"newest_ts": "2026-11-11T09:12:00Z",
"statuses": [ { "status_id": "1998...", "type": "video",
"media_ptr": "https://blob.cdn.example/1998.../ct", // ciphertext
"enc_key_ref": "k_42_self", // client decrypts K locally
"expires_at": "2026-11-12T09:12:00Z" } ] } ] }
Ordering: unseen authors first (by recency), then seen. p99 < 200ms (metadata+keys only).
POST /api/v1/status/{id}/seen # fire-and-forget, async, idempotent
Body: { } # viewer inferred from auth
Response 202 Accepted # never blocks playback
GET /api/v1/status/{id}/viewers?limit=50&cursor=... # author only
Response 200:
{ "count": 842, "viewers": [ { "id": "77", "seen_at": "..." } ],
"next_cursor": "<opaque>" } // only viewers who allow receipts; capped window
POST /api/v1/status/senderkey/distribute # when audience grows/rotates
Body: { "recipients": [ { "uid": "88", "enc_sender_key": "..." } ] }
Response 202 // pairwise-wrapped sender keys, server stores opaque
Pagination is cursor-based (viewer lists change under you as views stream in). The cursor encodes the last-returned viewer position.
Data store choices
Every store is a TTL’d KV store, nothing relational, because the data is disposable, partition-scoped, write-heavy, and needs no cross-entity ACID or joins. Native TTL does expiry for free. The security property - the server stores only ciphertext and opaque key blobs - is baked into the schema.
1. Object Store (encrypted media) - not a database. One ciphertext per status, content-addressed sha256(ct), immutable, 25h lifecycle expiry, fronted by CDN. ~150 TB steady state. The bytes are opaque.
2. Status Meta - KV with TTL. Point lookup by status_id. ~15 GB total. TTL 24h.
Table: status_meta
status_id BIGINT PARTITION KEY -- Snowflake, encodes created_at
author_id BIGINT
blob_key STRING -- sha256 of ciphertext
type ENUM -- image | video | text
duration_ms INT
created_at TIMESTAMP -- embedded in status_id
expires_at TIMESTAMP -- read-time filter
TTL: 86400 sec
Shard by: status_id (uniform; single-key reads hit one shard)
3. Status Inbox - KV with TTL (the fan-out target). Per recipient, the statuses delivered to them.
Key: inbox:{recipient_id}
Value: list of { status_id, author_id, enc_key_ref, media_ptr }
TTL: 86400 sec (per entry; also read-time filtered)
Shard by: recipient_id (a viewer's whole inbox colocates -> one read on list-load)
Write pattern: fan-out appends one tiny record per eligible recipient on post
4. Key Store - KV with TTL, server-opaque. Encrypted sender keys and per-status key references; the server cannot read any of it.
Key: skey:{author_id}:{recipient_id} -> enc_sender_key (pairwise-wrapped, opaque)
TTL / rotation: re-written on audience change; aged out with session
Shard by: author_id
5. Seen-State Store - KV with TTL (the 250 GB hot core). Per-(viewer, author) high-water mark.
Key: seen:{viewer_id}:{author_id} -> last_seen_status_id (unseen iff newest_active > this)
TTL: 86400 sec
Shard by: viewer_id (colocate a viewer's seen-set -> one batched read on list-load)
6. Viewer-List + View-Counter - KV with TTL. Capped recent viewers plus a sharded counter.
Key: viewers:{status_id} -> capped recent list (~2000), TTL 24h
Key: viewcount:{status_id}:{shard} -> integer, TTL 24h, summed on read (kills hot key)
Shard by: status_id (list); counter fanned across N sub-shards
Why NoSQL/KV over SQL, across the board: disposable, partition-scoped, write-heavy (15B inbox writes + 5B seen-writes/day), no cross-entity transactions or joins, and native TTL that does our expiry for free - the exact inverse of what SQL sells. The only durable relational data in the wider product (accounts, per-user “who added me” bits) lives in a separate small store, read as a cached copy, never joined live against the ephemeral hot path.
Bottlenecks & Scaling
Where it breaks first, in order, and the fix for each.
1. Fan-out write volume (breaks first - 15B inbox writes/day, ~520K/sec peak). The real load, 100x the post rate.
Fix: fan out only tiny records (status_id + enc_key_ref + pointer), never media; TTL’d KV inbox sharded by recipient_id; batch appends; the contacts graph caps fan-out at O(contacts), which is bounded unlike public followers. Online recipients also get a push over the existing messaging transport.
2. Seen-state write volume (5B/day, ~175K/sec peak).
Fix: async, best-effort, idempotent high-water-mark writes into TTL’d KV sharded by viewer_id; accept low durability (a lost marker re-shows one status once). Never synchronous or durable.
3. Per-recipient encryption cost on post. Fix: encrypt media once symmetrically (one shared CDN blob), fan out only the small key, and reuse sender keys so the per-recipient key handoff happens once per session, not once per status. Rotate the sender key only on audience shrink (removal/exclusion) to fail closed.
4. Sender-key rotation storms (a user with a big audience toggling exclusions). Fix: rotation re-wraps the sender key for the remaining N-1 contacts - bounded by contact count, and rare relative to posting. Batch rotations; a user editing a privacy list triggers one rotation, not one per subsequent post.
5. Celebrity / large-audience accounts. Bounded by the contacts model (no 100M-follower accounts), but business/large-contact accounts exist. Fix: cap status audience at the contacts graph’s realistic ceiling; shard the viewer-count counter across N sub-keys (sum on read) so no single key takes millions of increments; replicate hot author-index keys with a few-second local LRU.
6. Media egress bandwidth (58-175 GB/sec of ciphertext). Real but solved. Fix: CDN in front of immutable, content-addressed ciphertext, >95% edge hit, origin shield for viral status. Ciphertext caches exactly like any immutable blob.
7. Expiry / auto-delete correctness (zombie status = privacy breach).
Fix: native TTL at write on all six stores (blob 25h, rest 24h) + read-time now < expires_at filter. No scheduled delete job; independent TTLs, no cross-store delete transaction.
8. Privacy correctness under races. Fix: privacy enforced cryptographically, not by server ACL - no key, no plaintext, so a buggy fan-out cannot leak content. Exclusion rotates the sender key. Fail closed is the default because the absence of a key is unforgeable.
9. Shard keys, stated plainly. Blob store -> status_id/content-hash (uniform). Status meta -> status_id. Inbox -> recipient_id (colocate a viewer’s inbox for one read - the important one; sharding inbox by status_id would scatter every list-load). Seen-state -> viewer_id. Key store -> author_id. Viewer list -> status_id, counter sub-sharded. Never shard on created_at - it pins all “now” writes to one shard, the classic mistake and especially tempting when everything is time-bounded.
10. Single points of failure. Status / Fan-out / List-View Services are stateless behind load balancers. Every KV store, the CDN, and the blob store are replicated. Async seen and fan-out writes flow through a partitioned, replicated queue with idempotent consumers. No single box whose loss stops the list or playback; a lost async worker just delays a marker.
11. Multi-region for a global base. Fix: CDN edges are global. Deploy Fan-out/List/View services and KV stores per region, routed by GeoDNS; presigned uploads target the nearest region. Because all status data is eventually consistent and expires in 24h, cross-region replication is maximally forgiving - a region losing an hour of state self-heals within a day as everything expires. Ephemerality makes the system trivially region-resilient. Encryption keys are device-held, so no region holds a decryption secret worth protecting beyond the opaque wrapped blobs.
Wrap-Up
The trade-offs that define this design:
- End-to-end encryption forces one-blob-plus-key fan-out. Encrypt the media once with a symmetric key, store a single shared ciphertext, and fan out only the small key - wrapped per recipient, reused across statuses via sender keys. Encrypting per recipient would 100x storage; this keeps it at one blob while the server never sees plaintext.
- Privacy is cryptographic, not an ACL. The recipient set is computed on the poster’s device over the mutual-contacts graph and privacy lists; eligibility is enforced by who holds a key, so an excluded contact is locked out unforgeably and removals fail closed via sender-key rotation. The server never holds the global contact graph.
- Fan-out on write of tiny records - because the contacts graph caps it. Unlike a public feed with 100M-follower accounts, Status visibility is bounded by your phone book, so fanning a ~100-byte key+pointer record into each recipient’s inbox is affordable and is also the natural place to carry the per-recipient key. Media stays one shared CDN blob.
- Ephemerality inverts storage. The entire active corpus is ~15 GB metadata + ~250 GB seen-state + ~150 TB ciphertext, flat forever, all in TTL’d stores that self-expire at 24h. Auto-delete is a free datastore property plus a read-time filter, not a delete-storm cron, and “it disappears” is a kept promise rather than a batch job.
- Low durability is a feature. Losing an in-flight seen-marker or a few seconds of cross-region lag costs nothing, so durability is traded for the throughput the 15B-fan-out and 5B-seen writes per day demand.
One-line summary: an ephemeral, end-to-end-encrypted broadcast where status media is encrypted once into a single shared ciphertext blob, the small media key is fanned out per contact via reusable sender keys over a device-computed mutual-contacts graph with cryptographically enforced privacy, tiny key-and-pointer records are fanned out on write into per-recipient TTL’d inboxes, seen/unseen is a per-(viewer, author) Snowflake high-water mark, and every store self-expires at 24 hours so auto-delete is free and the server only ever relays ciphertext it cannot read.
Comments