Designing Disappearing Messages: Time-Bound, Cross-Device Consistent Deletion

Designing Disappearing Messages Time-Bound, Cross-Device Consistent Deletion

Designing Disappearing Messages at Global Scale

Building a messaging platform feature that guarantees time-bound message deletion consistently across every device, cache, and replica — the way Signal, WhatsApp, and Telegram do it. A deep dive into TTL indexes, sync fan-out, reconciliation sweeps, and the cross-device consistency guarantees that turn a countdown timer into an enforced, verifiable fact.

01

Introduction & History

Imagine writing a note, handing it to a friend, and watching it dissolve into ash the moment they finish reading it. That is the promise of a “disappearing message” — a message that lives for a while and then, provably, stops existing. It sounds simple. In practice, it is one of the more deceptively hard problems in distributed systems, because a messaging platform is never just one place where a message lives. The same message might sit in a server’s primary database, a read replica, three separate device caches (phone, laptop, tablet), a push-notification preview banner, a local search index, and a backup snapshot — all at once. “Disappearing” has to mean disappearing from all of those places, on a schedule that all devices agree on, even when some of those devices are offline for days.

The feature traces its roots to Snapchat, which in 2011 built an entire consumer product around ephemeral photos — content that vanished after being viewed. The idea was provocative: what if the default assumption of the internet, that everything is permanent, could be inverted? Messaging platforms took notice. Telegram introduced “Secret Chats” with self-destruct timers in 2013. WhatsApp added “Disappearing Messages” globally in 2020, with configurable timers of 24 hours, 7 days, and 90 days. Signal, being privacy-first by design philosophy, built disappearing messages as a first-class citizen of its protocol rather than a bolt-on feature, supporting timers as short as 30 seconds.

What changed between “Snapchat as a novelty” and “disappearing messages as infrastructure” is scale and trust. Modern users expect this feature to work identically whether they’re on a flaky mobile network in a stadium or a fiber connection at a desk, whether they read the message immediately or three weeks later after their phone was off. And crucially, they expect the deletion to be enforced — not just hidden by a UI flag while the raw bytes sit untouched on a server disk somewhere. Building that guarantee, at the scale of billions of messages per day, is the subject of this tutorial.

Everyday analogy

Think of a hotel key card that expires at checkout. The card itself keeps working locally in the door until its embedded expiry is checked; the front desk also revokes it in the central system; housekeeping removes any duplicates left in the room. Multiple independent enforcement points, all agreeing on the same expiry, are what actually make the room secure — not any single one of them alone. A disappearing-messages system is the same idea, at planetary scale, with the “doors” being databases, caches, devices, and backups.

💬
What the interviewer may ask
  • “Why is disappearing messages harder than just adding a delete timestamp to a message row?”
  • “What’s the difference between ephemeral messaging (Snapchat-style, view-once) and time-bound disappearing messages (WhatsApp-style, timer-based)?”
  • “How would you explain to a non-technical stakeholder why ‘delete after 24 hours’ is a distributed systems problem, not a database problem?”
02

Architecture & Core Components

At a high level, a disappearing-messages subsystem is layered on top of an existing messaging platform’s send/receive pipeline. It doesn’t replace the core chat architecture — it augments every stage of it with a “time-to-live” (TTL) concept that must be respected end-to-end. Let’s look at the major components.

flowchart TB subgraph Client[“Client Devices”] C1[“Phone App”] C2[“Desktop App”] C3[“Web Client”] end subgraph Edge[“Edge Layer”] LB[“Global Load Balancer”] GW[“API and WebSocket Gateway”] end subgraph Core[“Core Messaging Services”] MSG[“Message Service”] TTL[“TTL and Expiry Service”] SYNC[“Device Sync Service”] PUSH[“Push Notification Service”] end subgraph Storage[“Storage Layer”] DB[“Primary Message Store sharded DB”] REPL[“Read Replicas”] CACHE[“Distributed Cache Redis”] QUEUE[“Deletion Job Queue Kafka”] COLD[“Cold and Backup Storage”] end subgraph Workers[“Background Workers”] REAPER[“Expiry Reaper Workers”] RECON[“Reconciliation Job”] end C1 –> LB C2 –> LB C3 –> LB LB –> GW GW –> MSG MSG –> TTL MSG –> DB TTL –> QUEUE QUEUE –> REAPER REAPER –> DB REAPER –> CACHE REAPER –> COLD DB –> REPL MSG –> SYNC SYNC –> PUSH SYNC –> GW RECON –> DB RECON –> CACHE RECON –> C1 RECON –> C2 RECON –> C3
Figure 2.1 — High-level architecture of a disappearing-messages subsystem layered over a messaging platform.
Component

Message Service

The same component that handles ordinary message send/receive in any chat platform, extended to accept and persist a TTL attribute per message (or inherited from a per-conversation default). Every message that enters the system carries three time-related fields: created_at, ttl_seconds, and a derived expires_at. This triad is the seed of everything downstream.

Component

TTL / Expiry Service

The brain of the feature. It does not store messages itself; it tracks when things should stop existing. Think of it as a specialized scheduler purpose-built for extremely high cardinality, short-lived timers — billions of them, each firing exactly once, at exactly the right moment. It typically works by writing a lightweight pointer (conversation ID, message ID, expiry timestamp) into a time-ordered structure rather than scanning the full message table.

Component

Deletion Job Queue

A durable, ordered queue (commonly Kafka or a cloud-native equivalent) that receives “this message has expired” events and fans them out to workers. Decoupling detection-of-expiry from execution-of-deletion is the single most important architectural decision in this system, because it lets you retry failed deletions, scale reaper workers independently, and audit what was deleted and when.

Component

Expiry Reaper Workers

Stateless worker fleets that consume deletion events and perform the actual multi-target delete: removing the row from the primary datastore, invalidating cache entries, and instructing connected clients to purge local copies. “Reaper” is a fitting name — these workers do nothing but harvest expired content, continuously, at whatever rate the traffic demands.

Component

Device Sync Service

Responsible for propagating state changes — including deletions — to every device associated with a user’s account. This is the component most directly responsible for the “consistently across all devices” requirement. It maintains a per-device sync cursor and delivers deletion events even to devices that were offline when the expiry actually happened.

Component

Reconciliation Job

A periodic (and event-triggered) sweep that catches anything the “happy path” missed — messages that expired but weren’t deleted due to a crashed worker, a dropped queue message, or a device that never came back online to receive its sync instruction. This is the system’s insurance policy.

💬
What the interviewer may ask
  • “Why decouple the TTL/Expiry Service from the Message Service instead of having the Message Service handle its own timers?”
  • “What happens if the Deletion Job Queue goes down for ten minutes — do messages fail to disappear, or fail to be delivered?”
  • “Why do you need a separate Reconciliation Job if the Reaper Workers already handle deletion?”
03

Internal Working: How a Timer Becomes a Guaranteed Deletion

Let’s trace the mechanics of turning “delete this in 24 hours” into an enforced, verifiable fact.

3.1 The expiry index problem

A naive approach stores a message row with an expires_at column and periodically runs a query like “find all rows where expires_at < now and delete them.” At small scale this works fine. At the scale of a platform sending tens of billions of messages a day, this becomes catastrophic: scanning a massive table for a sparse set of expired rows, even with an index, creates enormous read amplification, lock contention, and replication lag, especially because expiries cluster in bursts (everyone who set a 24-hour timer at 9am gets swept at 9am the next day).

The standard production solution is a time-bucketed expiry index — conceptually similar to a hashed timing wheel, a data structure borrowed from operating-system timer subsystems. Instead of one giant sorted structure, expiry pointers are bucketed into coarse time windows (e.g., one bucket per minute). A background scheduler advances through buckets in order, and only ever touches the bucket whose time has arrived. This converts an $O(n)$ scan over the whole message corpus into an $O(k)$ operation over just the messages expiring in that particular minute.

3.2 Two-phase expiry: detect, then execute

Expiry detection and expiry execution are deliberately separated:

  • Detect: The TTL Service’s bucket scheduler determines that a batch of message IDs has crossed its expiry threshold and emits one event per message (or per small batch) onto the Deletion Job Queue.
  • Execute: Reaper Workers consume those events and perform the actual deletion across every storage target — primary DB, cache, search index, cold storage — and notify the Sync Service.

This separation means detection can be extremely fast and lightweight (just identifying “what’s due”), while execution can be retried, parallelized, and rate-limited independently without ever risking a missed detection.

3.3 Idempotent deletion

Because queues offer at-least-once delivery in almost every real-world configuration, a deletion event might be processed twice. Reaper Workers are therefore designed to be idempotent: deleting an already-deleted message is a no-op, not an error. This is typically achieved by having the delete operation be a conditional delete (delete-if-exists) rather than an unconditional one, and by having sync-fanout messages carry a message ID so duplicate delivery to a client is simply ignored.

sequenceDiagram participant Sender participant MsgSvc as Message Service participant TTL as TTL Service participant Queue as Deletion Queue participant Reaper as Reaper Worker participant DB as Primary Store participant Cache participant Sync as Sync Service participant Recv as Recipient Devices Sender->>MsgSvc: Send message ttl 24h MsgSvc->>DB: Persist message and expires_at MsgSvc->>TTL: Register expiry pointer MsgSvc->>Recv: Deliver message with ttl metadata Note over Recv: Local timer starts on read or delivery TTL->>TTL: Bucket scheduler advances to due time TTL->>Queue: Emit expiry event message_id Queue->>Reaper: Consume event Reaper->>DB: Conditional delete delete-if-exists Reaper->>Cache: Invalidate cached copy Reaper->>Sync: Notify deletion occurred Sync->>Recv: Push purge message_id to all devices Recv->>Recv: Remove from local DB cache UI and search index
Figure 3.1 — End-to-end sequence from message send to enforced, synced deletion.

3.4 Two different timer semantics

It’s important to distinguish two flavors of TTL, because they demand different internal mechanics:

Timer TypeTriggerExample
Send-based TTLClock starts when the message is sent/createdWhatsApp’s 24-hour / 7-day / 90-day disappearing messages
Read-based TTL (view-once)Clock starts when the recipient opens/reads the messageSignal’s per-message timer, Snapchat photos

Send-based TTL is architecturally simpler because the expiry timestamp is known the moment the message is created — it’s a pure server-side scheduling problem. Read-based TTL is harder: the server doesn’t know when the timer starts until it receives a “read receipt” event from a specific device, and it must handle the case of multiple devices, where “read” needs a precise, race-free definition (e.g., first device to open it starts the clock for everyone).

3.5 A closer look at the timing wheel

The timing-wheel concept deserves a concrete walkthrough because it’s the piece most likely to come up as a whiteboard exercise. Picture a circular array of buckets — say, 1,440 buckets, one per minute in a 24-hour wheel. Each bucket holds a lightweight list of pointers (message ID plus shard reference) for everything expiring in that minute. A single background pointer, the “current minute cursor,” advances one bucket per minute. When the cursor lands on a bucket, every pointer in it is drained and turned into a deletion event; the bucket is then emptied and made available for reuse roughly 24 hours later. For TTLs longer than the wheel’s total span (WhatsApp’s 90-day option, for example), a second, coarser wheel — hours or days per bucket — holds far-future expiries and periodically “promotes” entries into the fine-grained minute wheel as they get closer to firing. This two-tier (or multi-tier) hierarchical wheel is exactly the same technique used inside operating system kernels and network stacks to manage millions of concurrent timers cheaply, and it generalizes well to a sharded, distributed setting because each shard simply runs its own independent wheel.

3.6 Concurrency inside a single bucket

A subtlety worth internalizing: multiple reaper workers may pull from the same due bucket concurrently once volume is high enough that a single worker can’t drain it within its minute window. The bucket’s contents are therefore treated as a work queue with per-item claim semantics (a lightweight lease or visibility timeout, similar to how SQS or Kafka consumer groups hand out partitions) so that two workers never race to process — and potentially double-count metrics for — the same expiring message. If a worker crashes mid-processing, its claimed-but-unfinished items become visible again after the lease expires, and another worker picks them up, which is what makes the whole pipeline self-healing at the worker level, independent of the separate, coarser reconciliation sweep described later.

ReaperWorker.java — idempotent, at-least-once deletion with lease-based claim
public void handleExpiryEvent(ExpiryEvent e) {
    // Claim with a short lease; if we crash, another worker re-picks after expiry.
    if (!bucketLease.claim(e.getBucketId(), e.getMessageId(), LEASE_TTL)) {
        return; // another worker already owns this item
    }
    try {
        // delete-if-exists: no-op on the second delivery, so at-least-once is safe.
        DeleteResult r = messageStore.deleteIfExists(e.getMessageId(), e.getExpectedExpiresAt());
        cache.invalidate(e.getMessageId());
        searchIndex.remove(e.getMessageId());
        syncService.fanoutPurge(e.getConversationId(), e.getMessageId());
        metrics.recordDeletion(r);
    } finally {
        bucketLease.release(e.getBucketId(), e.getMessageId());
    }
}
💬
What the interviewer may ask
  • “Why not just run a cron job every minute that does DELETE WHERE expires_at < NOW()? What breaks at scale?”
  • “Explain how a timing-wheel-style bucketed index reduces the cost of expiry detection.”
  • “How would a hierarchical, multi-tier wheel support both a 30-second TTL and a 90-day TTL efficiently in the same structure?”
  • “How would you design the ‘read starts the timer’ semantics when a message can be opened simultaneously on two devices?”
  • “Why must the reaper’s delete operation be idempotent, and how do you implement that concretely?”
  • “How do you prevent two reaper workers from double-processing the same expiring bucket entry?”
04

Data Flow & Message Lifecycle

A disappearing message moves through a well-defined lifecycle. Understanding each stage clarifies where consistency guarantees must be enforced.

1
Creation — Sender composes a message in a conversation with disappearing messages enabled (either globally for the chat or per-message). The TTL is attached at creation time.
2
Persistence — The Message Service writes the message plus its expires_at to the primary store, and registers the expiry pointer with the TTL Service atomically (ideally within the same transaction or via a reliable outbox pattern).
3
Fan-out delivery — The message (with its TTL metadata) is pushed to every currently-connected device belonging to the recipient, and queued for offline devices via push notification wake-up plus a pending-sync marker.
4
Local caching — Each receiving device stores the message locally (for offline access, fast scrollback, search) — and critically, stores the same expires_at value, not just a countdown timer, to be resilient to app restarts and clock drift.
5
Countdown / dwell — The message is live and readable for the duration of its TTL window.
6
Expiry detection — The TTL Service’s scheduler determines the message has crossed its expiry boundary.
7
Server-side deletion — Reaper Workers remove the message from primary storage, replicas (via normal replication of the delete), cache, and any derived indexes (search, media thumbnails).
8
Client-side purge fan-out — The Sync Service instructs every device — online now, or the next time it connects — to delete its local copy.
9
Local purge — Each device removes the message from its local database, in-memory cache, notification tray, search index, and any UI state (like a chat preview snippet).
10
Reconciliation sweep — A periodic job cross-checks that no expired message remains anywhere, catching gaps from crashed workers or long-offline devices.

4.1 The outbox pattern for atomic registration

Step 2 above hides a subtle correctness requirement: if the Message Service writes the message to the primary store but then crashes before registering the expiry pointer with the TTL Service, that message will never expire — a silent, dangerous failure for a privacy feature. The standard fix is the transactional outbox pattern: the message write and an “expiry-registration” event write happen in the same database transaction, and a separate relay process reliably publishes that event to the TTL Service. This guarantees that if the message exists, its expiry registration will eventually exist too.

4.2 Offline devices and deferred sync

A device that’s offline when a message expires doesn’t miss the deletion — it defers it. The Sync Service maintains a durable, ordered log of sync events per user (or per device), similar to a changelog. When a device reconnects, it doesn’t just ask “what’s new,” it asks “give me everything since my last cursor position,” which includes both new-message events and delete events in their correct order. This ordering matters: if a device were to apply events out of order, it could resurrect a message that should have stayed deleted, or fail to display one that was legitimately still active when it went offline.

💬
What the interviewer may ask
  • “Walk me through what happens if a device is offline for two weeks and reconnects — how does it catch up without re-displaying expired messages?”
  • “What’s the transactional outbox pattern and why is it necessary here specifically?”
  • “Why is event ordering critical in the sync log, and what would go wrong if delete-events and create-events could be applied out of order?”
05

Cross-Device and Local Cache Consistency Enforcement

This is the crux of the problem statement, so it deserves its own dedicated section rather than being buried inside “architecture.” The naive mental model — “the server deletes it, so it’s deleted” — is wrong, because in any real messaging app the source of truth is distributed across multiple physical copies that the server does not have direct write access to: the phone’s local SQLite database, the desktop app’s IndexedDB store, the OS-level notification center’s cached preview text, and even backup snapshots stored in cloud backup services like iCloud or Google Drive.

5.1 The “push, don’t just trust” principle

The server cannot simply assume clients will honor a TTL value they were given at delivery time. Clients might have bugs, might be offline exactly when the timer should fire, or might belong to an old app version that mishandles TTL. The system must therefore actively push a deletion instruction to every device, rather than relying purely on each device independently computing “my local timer says this should be gone now.” The client-side timer is a UX nicety (so the message visibly counts down or vanishes without waiting on a network round-trip); the server-pushed deletion event is the enforcement mechanism.

5.2 Sync fan-out design

Every account is modeled as a set of registered devices, each with its own persistent connection (or push-notification wake channel) and its own sync cursor. When the Reaper Worker deletes a message, it doesn’t send one deletion instruction — it enumerates every device on the account and enqueues a per-device deletion instruction, delivered through whichever channel that device currently uses (live WebSocket push if connected, silent push notification plus pending-sync-log entry if not).

flowchart LR EXP[“Message Expires server confirmed”] –> FAN[“Sync Fan-out”] FAN –> D1[“Device 1 Online WebSocket push”] FAN –> D2[“Device 2 Offline queued in sync log”] FAN –> D3[“Device 3 Backgrounded silent push wake”] D1 –> P1[“Immediate local purge”] D2 –> P2[“Purge applied on reconnect”] D3 –> P3[“App wakes applies purge updates cache silently”] P1 –> V[“Verification ACK sent to server”] P2 –> V P3 –> V V –> REC[“Reconciliation Job checks all devices ACKed”]
Figure 5.1 — Sync fan-out ensures deletion reaches every device regardless of connectivity state.

5.3 Handling local caches beyond the message store

A message doesn’t live in just one local table. A thorough client-side purge must touch:

  • Local message database (SQLite/Realm/IndexedDB row for the message itself)
  • In-memory chat view cache (the currently rendered message list in the UI layer)
  • Local full-text search index (so a deleted message can’t be found by searching old conversations)
  • Notification center / lock screen previews (OS-level cached notification text must be revoked, not just the app’s internal copy)
  • Media cache (thumbnails, downloaded images/videos referenced by the message)
  • Chat list preview snippet (“last message” text shown in the conversation list)

Missing any one of these creates a “ghost” — content that’s technically deleted from the database but still visible somewhere in the UI or OS chrome, which is exactly the kind of bug that erodes user trust in a privacy feature.

5.4 Clock skew and local timer drift

Devices lie about the time — sometimes innocently (unsynced clocks, timezone bugs), sometimes deliberately (a user trying to “freeze” a countdown by manipulating device time to screenshot content before it disappears). For this reason, the countdown shown in the UI is cosmetic; the authoritative expires_at is always a server-issued, server-monotonic timestamp, and the actual deletion instruction is server-driven, never purely client-timer-driven. Well-designed clients periodically reconcile local clocks against server time (similar to NTP-style skew correction) purely for display accuracy, but never use local wall-clock time as the trigger for the authoritative delete.

5.5 Backups: the overlooked consistency boundary

Cloud backups (iCloud, Google Drive chat backups) are a common blind spot. If disappearing messages are naively included in an encrypted backup blob taken before expiry, restoring that backup after the message would have expired can resurrect deleted content. Mature implementations either exclude disappearing messages from backups entirely, or apply the same TTL logic during restore (checking expires_at against current time before rehydrating a message into the local store).

5.6 Consistency model: why eventual consistency is the right choice here

It’s tempting to reach for strong, linearizable consistency across every device — “the message is gone everywhere at the exact same instant, guaranteed.” In practice this is neither achievable nor necessary in a system with intermittently-connected mobile clients: a phone in airplane mode cannot receive an instruction in real time no matter what consistency model the server promises. The realistic and honest model is eventual consistency with a bounded convergence target: every online, healthy device purges within a tight SLO (commonly low single-digit seconds), and every device — no matter how long it was offline — is guaranteed to purge on its very next reconnect, before it’s allowed to render any new content from that conversation. This ordering guarantee (deletions are applied before the device is considered “caught up”) is what prevents an expired message from ever being shown, even though the literal timing of the purge across the fleet of devices is not simultaneous.

5.7 Applying CAP trade-offs to this problem

Framed through the CAP theorem, the deletion pipeline deliberately favors consistency of the eventual outcome over availability of an instantaneous global view: a device that cannot yet be reached (partitioned, offline) simply defers, rather than the system attempting some impossible synchronous “delete on all devices right now or fail the operation” transaction. This is the correct trade-off for the problem — refusing to let a user send a disappearing message just because one of their other devices happens to be offline would be a poor product experience for a guarantee that mobile networking makes fundamentally best-effort in real time anyway. What must never be sacrificed is order: a device must never apply a “new message” event from before its last delete event out of sequence in a way that could redisplay something already purged elsewhere.

💬
What the interviewer may ask
  • “A user says a disappearing message is still showing on their lock screen notification an hour after it should have expired. What’s your debugging approach?”
  • “Why can’t the client’s local countdown timer be trusted as the actual deletion trigger?”
  • “How do you prevent a cloud chat backup from resurrecting an expired message on restore?”
  • “Design the sync cursor mechanism that lets an offline device catch up correctly on both new messages and deletions.”
  • “Why is eventual consistency, rather than strong consistency, the right model for cross-device deletion — and what’s the one ordering guarantee you cannot give up even under eventual consistency?”
06

Advantages, Disadvantages & Trade-offs

✓ Advantages

  • Reduces long-term storage footprint and cost at scale
  • Strong privacy value proposition, competitive differentiator
  • Limits blast radius of account compromise or device theft
  • Regulatory alignment with data-minimization principles (e.g., GDPR storage limitation)
  • Reduces value of server as a target for mass data breaches (less data to steal, sooner)

✗ Disadvantages / Trade-offs

  • Significant engineering complexity across every layer (server, cache, client, backup)
  • Screenshot/second-device photography remains an unclosed loophole in most implementations
  • Harder to debug support issues (“where did my message go”) since evidence disappears by design
  • Complicates legal hold / compliance workflows where retention is mandated
  • Increased background job load (reaper workers, reconciliation sweeps) adds infra cost even as storage cost drops

The central trade-off is between storage efficiency and privacy guarantees on one side, and operational complexity and support/compliance friction on the other. A platform serving regulated industries (banking, healthcare) may need to layer legal-hold exceptions on top of disappearing messages, which reintroduces some of the complexity the feature was meant to eliminate.

💬
What the interviewer may ask
  • “How would you reconcile a legal hold requirement with a user’s expectation that messages truly disappear?”
  • “What’s the fundamental trade-off this feature makes, and who bears the cost of that trade-off?”
07

Performance & Scalability

At the scale of a major platform — think hundreds of millions of daily active users, tens of billions of messages per day — a meaningful fraction of which carry TTLs, the expiry subsystem must handle enormous, bursty write and delete volume without degrading the primary send/receive path.

7.1 Sharding the expiry index

The bucketed timing-wheel index described earlier is itself sharded, typically co-located with the message shard it references (shard-by-conversation or shard-by-user, matching however the primary message store is partitioned). This avoids cross-shard coordination during both registration and expiry-firing, keeping each shard’s reaper workload independent and horizontally scalable.

7.2 Batching deletions

Rather than emitting one deletion event per message, production systems batch expiry events that fall within the same small time window and same shard into a single batched delete operation, dramatically reducing the number of discrete transactions against the primary store and the number of discrete cache invalidation calls.

7.3 Handling expiry storms

Because many users pick “round” TTLs (24 hours, exactly), expiries cluster at predictable peaks — for example, a spike of expiries at whatever local hour messaging activity is highest, 24 hours later. Systems mitigate this “thundering herd” of expiries by jittering non-critical aspects of bucket processing (spreading batch execution across a small window rather than firing all buckets for a given minute simultaneously) and by autoscaling reaper worker fleets based on queue depth rather than fixed capacity.

7.4 Read path impact

Disappearing messages must not slow down the hot read path (opening a chat, scrolling history). This is achieved by never filtering “is this expired” at query time against a live clock comparison across the whole result set; instead, expired messages are proactively removed by the reaper before they’d ever be read, so the steady-state read query is a plain, index-friendly range scan with no additional runtime filtering cost. A defensive expires_at check is still applied application-side as a last-resort filter, purely as a safety net for the rare race where a message expired microseconds before being read.

7.5 Capacity planning for the deletion pipeline

Capacity planning for this subsystem is driven by a different curve than the core messaging path. Send/receive traffic tends to track daily active usage fairly smoothly across a 24-hour cycle with familiar peaks around commute times and evenings. The deletion pipeline’s load, by contrast, is a convolution of send volume with the distribution of chosen TTLs — a spike in sends at 9am with a popular 24-hour TTL produces a corresponding spike in deletions at 9am the following day, largely independent of how busy the platform happens to be at that moment. Sizing the reaper fleet purely off current traffic metrics can therefore under-provision for a deletion spike that’s actually driven by yesterday’s send volume. Mature systems forecast expected deletion load a day (or a TTL-window) ahead by summing registered-but-not-yet-fired expiries per upcoming time bucket, and use that forward-looking number to drive proactive autoscaling rather than reactive scaling purely off current queue depth. This is one of the few places in the overall messaging platform where tomorrow’s load is already fully knowable today, simply by reading the expiry index — a nice property worth exploiting operationally.

1,440Buckets in a 24-hour minute-granularity wheel
O(k)Expiry-detection cost per fired bucket
24 h aheadDeletion load already knowable from the index
< 1 sTypical p50 expiry-to-deletion latency SLO
💬
What the interviewer may ask
  • “How do you avoid a thundering herd of expiries all firing at once when many users choose the same 24-hour TTL?”
  • “Why shouldn’t you filter expired messages at read time with a live clock comparison across a large table?”
  • “How would you scale reaper workers to handle traffic 10x higher than today without over-provisioning at idle times?”
08

High Availability & Reliability

A disappearing-messages system has two very different failure modes to guard against, and they pull in opposite directions: failing to delete on time (a privacy leak — the message lingers longer than promised) and deleting too early or incorrectly (a data-loss bug — the message vanishes before its promised time, or a non-expiring message gets swept by mistake). Reliability engineering here is about minimizing both failure modes simultaneously.

8.1 At-least-once detection, idempotent execution

As covered earlier, the queue guarantees at-least-once delivery of expiry events, and reaper workers are idempotent, so duplicate processing is safe. The remaining risk is the opposite direction: an expiry event that’s never emitted at all, due to a crashed scheduler shard or a lost bucket. This is where reconciliation sweeps earn their keep — a separate, independent process periodically scans for any message whose expires_at has passed but which still exists in primary storage, and re-emits a deletion event for it. This acts as a self-healing backstop that doesn’t depend on the primary detection path having worked correctly.

8.2 Multi-region considerations

For a globally distributed platform, the TTL Service and Reaper Workers typically run per-region, close to the shard they operate on, to avoid cross-region latency in the hot deletion path. Cross-region replication of the primary store (for disaster recovery) must also replicate deletes correctly and promptly — a common pitfall is a replication topology that’s optimized for insert-heavy workloads but lags on deletes, leaving a “deleted” message visible in a disaster-recovery replica for far longer than intended.

8.3 Graceful degradation

If the Deletion Job Queue becomes unavailable, the system should degrade by pausing new message sends’ expiry-registration acknowledgment (or queuing registrations locally) rather than silently losing them, and should alert on-call engineers immediately, since every minute of queue downtime is a growing backlog of privacy-sensitive content that isn’t being cleaned up on schedule.

📌
The two-failure-mode discipline

Every design decision on the deletion path must be evaluated against both failure modes explicitly. A knob that reduces “late delete” risk (e.g., aggressive retry on ambiguous state) usually increases “wrong delete” risk, and vice versa. Naming both failure modes on whiteboard and drawing which mitigation biases which way is what separates a mature answer from a superficial one.

💬
What the interviewer may ask
  • “Which failure mode is worse for this feature: deleting late, or deleting early/incorrectly? How does your design treat them differently?”
  • “How does the reconciliation sweep act as a self-healing mechanism independent of the primary detection path?”
  • “What happens to disaster-recovery replicas if deletes replicate slower than inserts?”
09

Security Considerations

Disappearing messages are fundamentally a privacy/security feature, so the security bar for the subsystem itself must be exceptionally high — a bug here isn’t just an inconvenience, it’s a broken promise to the user.

9.1 End-to-end encryption interplay

In end-to-end encrypted platforms (Signal, WhatsApp), the server never has plaintext access to message content in the first place — it stores and routes ciphertext. This actually simplifies part of the disappearing-messages security story: even if a deletion were somehow delayed, an attacker who breached the server would only obtain ciphertext they can’t read without the recipient’s private keys. However, TTL metadata itself (who messaged whom, when, and for how long the message was configured to live) is generally not end-to-end encrypted, since the server needs it to schedule expiry — this metadata is a smaller, but real, privacy surface that must still be protected.

9.2 Secure deletion vs. logical deletion

“Deleting” a database row often just marks storage as reusable rather than immediately overwriting the physical bytes on disk — the classic distinction between logical deletion and secure/physical erasure. For a genuinely security-sensitive disappearing-messages implementation, the underlying storage engine’s behavior matters: are deleted rows’ disk pages promptly reclaimed and eventually overwritten by new writes (typical of most OLTP databases and SSD wear-leveling over time), or does the system need to go further with explicit secure-erase / cryptographic-erasure techniques? A common, practical approach is cryptographic erasure: encrypt each message with a unique per-message key, and on expiry, delete the key rather than (or in addition to) the ciphertext — without the key, the ciphertext left behind (in a backup, a log, a snapshot) is unrecoverable.

9.3 Preventing message resurrection attacks

A malicious or buggy client shouldn’t be able to “resurrect” an expired message by, for example, replaying an old sync payload, restoring from a stale backup, or manipulating local clock/device time. Defenses include: server-authoritative expires_at that clients cannot override; sync payloads that are versioned and rejected if stale; and backup/restore flows that always re-validate expiry against current server time before rehydrating content.

9.4 The screenshot / second-device problem

No server-side architecture can prevent a recipient from photographing their screen with a second device — this is a fundamental limitation of the feature, not an engineering gap. Some platforms (Snapchat, Signal) add screenshot-detection notifications as a social deterrent, but this should be presented to users honestly as a deterrent, not a guarantee, since it’s trivially bypassed.

CryptographicErasure.java — deleting the per-message key as an additional expiry guarantee
public void onExpiry(MessageRef ref) {
    // Ciphertext may still linger in a backup or snapshot for a while, but
    // without its per-message key it is unrecoverable regardless.
    keyStore.destroy(ref.getMessageKeyId());
    messageStore.deleteIfExists(ref.getMessageId(), ref.getExpectedExpiresAt());
    auditLog.record(new KeyDestroyed(ref.getMessageKeyId(), Instant.now()));
}
💬
What the interviewer may ask
  • “What’s the difference between logical deletion and cryptographic erasure, and when would you choose the latter?”
  • “Why is TTL metadata a privacy concern even in an end-to-end encrypted system where content itself is protected?”
  • “How do you prevent a malicious client from resurrecting an expired message via a replayed sync payload?”
  • “How would you honestly communicate the limits of this feature (e.g., screenshots) to users?”
10

Monitoring, Logging & Metrics

Because correctness here is largely invisible to normal functional testing (the system “working” looks identical to messages just quietly disappearing on schedule), strong observability is what actually gives an engineering team confidence the feature is behaving correctly in production.

10.1 Key metrics

MetricWhy it matters
Expiry-to-deletion latency (p50/p95/p99)Measures the gap between “should be deleted” and “actually deleted” server-side
Deletion-to-sync-ack latency per deviceMeasures how long it takes a device to confirm it purged its local copy
Reconciliation sweep hit rateHow many expired-but-undeleted messages the backstop catches — should trend toward zero
Deletion queue depth / consumer lagEarly warning signal for a growing backlog before it becomes user-visible
Reaper worker error rateTracks failed delete attempts needing retry or investigation
Devices with stale sync cursor > N daysIdentifies accounts at risk of large deferred-deletion backlogs

10.2 Logging carefully

Logging is a genuine tension point for this feature: verbose debug logs that capture message content or even message IDs alongside user identifiers can themselves become an unintended, longer-lived copy of “deleted” data sitting in a log aggregation system. Best practice is to log only structural/metadata events (message ID hashes, timestamps, shard IDs, latency numbers) with strict retention policies on the logs themselves, and to never log plaintext content in any code path touching disappearing messages.

10.3 Alerting

Alerts should fire on: deletion queue lag exceeding a threshold, reconciliation sweep repeatedly finding non-zero backlogs (indicating the primary detection path is systematically failing, not just occasionally), and reaper worker fleets falling below healthy capacity. These are treated as high-severity, privacy-impacting incidents, not routine ops noise.

💬
What the interviewer may ask
  • “What’s the danger of over-logging in a disappearing-messages system, and how do you balance debuggability against that risk?”
  • “What metric would tell you the primary detection path is silently failing, forcing reliance on the reconciliation backstop?”
  • “How would you alert on this system differently than a typical CRUD service?”
11

Deployment & Cloud Architecture

The TTL Service, Deletion Job Queue, and Reaper Worker fleets are natural candidates for independent deployment and independent scaling from the core Message Service, since their load profile (bursty, background, latency-tolerant) is very different from the core send/receive path (latency-critical, user-facing). A typical cloud deployment separates them into their own service boundaries with their own autoscaling policies — reaper worker fleets, for instance, autoscale on queue depth rather than request rate, and can run on cheaper, more elastic compute (like spot/preemptible instances) since individual worker failures are safely retried.

Multi-region deployment keeps each region’s TTL scheduler and reaper workers operating against that region’s local shard of the primary store to minimize cross-region latency, while the Sync Service typically needs global routing awareness, since a user’s devices might be spread across regions (a phone roaming internationally, a laptop at a home office in a different region).

📌
Compute-tier choices

Because reaper workers are safely retriable and mostly latency-tolerant, they are exactly the workload profile that benefits most from cheaper, interruptible compute (spot / preemptible instances). Reserving expensive, long-lived capacity for a fleet that could just as safely be preempted is a common source of unnecessary cost in early implementations of this system.

💬
What the interviewer may ask
  • “Why would you deploy the Reaper Worker fleet separately from the core Message Service instead of running deletion logic inline?”
  • “What autoscaling signal makes sense for reaper workers, and why isn’t request-per-second the right metric here?”
12

Databases, Caching & Load Balancing

12.1 Primary store choice

Messaging platforms commonly use a horizontally sharded, wide-column or key-value oriented store (conceptually similar to Cassandra, DynamoDB, or a sharded relational system) partitioned by conversation ID or user ID, optimized for high write throughput and range scans over time-ordered message history. TTL support is sometimes available natively at the storage-engine level (many key-value and wide-column stores support native TTL columns/collections that auto-expire rows), which can offload some of the deletion mechanics to the database itself — though relying purely on native TTL is usually insufficient on its own for the cross-device sync guarantee, since native TTL only guarantees eventual server-side removal, not that connected clients were told to purge their own copies.

12.2 Caching layer

A distributed cache (Redis or similar) sits in front of the primary store to serve hot conversation reads quickly. Cache entries for messages carry the same expiry semantics as the underlying row — either via the cache’s own native TTL feature (set to match expires_at) as a redundant safety net, or via explicit invalidation from the Reaper Worker at delete time. Relying on cache-native TTL alone is risky because cache eviction timing can drift slightly from the authoritative server-side deletion event, so production systems generally treat cache-native TTL as a defense-in-depth backstop, with explicit invalidation as the primary mechanism.

12.3 Load balancing

Standard global load balancing (geo-DNS plus regional load balancers) routes client connections to the nearest healthy region for the core messaging path. For the deletion pipeline specifically, “load balancing” mostly takes the form of consumer group partitioning within the Deletion Job Queue — partitioning by shard ID so that a growing volume of expiries in one busy shard doesn’t starve reaper capacity for other shards.

💬
What the interviewer may ask
  • “Why is native database TTL insufficient by itself for this feature, even though it removes rows automatically?”
  • “How would you keep a Redis cache entry’s expiry in sync with the authoritative database expiry?”
  • “How do you partition the deletion queue to avoid one busy shard starving reaper capacity elsewhere?”
13

APIs & Microservices Design

The feature is best modeled as a small set of focused microservices communicating over well-defined APIs and events, rather than logic embedded inside the monolithic message-send path.

  • Message Service API: accepts message creation with optional ttl_seconds; internally calls the TTL Service to register expiry as part of a transactional outbox write.
  • TTL Service API: internal-only service exposing “register expiry,” “cancel expiry” (for message deletion-before-expiry by the user), and “reconcile” operations; emits expiry events onto the Deletion Job Queue.
  • Sync Service API: exposes a per-device “pull changes since cursor” endpoint plus a push channel for real-time delivery of both new-message and delete events.
  • Reaper Worker: not user-facing; a queue consumer with internal APIs to the primary store, cache, and Sync Service.
POST /v1/messages — send a message with a TTL
POST /v1/messages
{
  "conversationId": "c_88a...",
  "ciphertext":     "<base64>",
  "ttlSeconds":     86400   // 24-hour disappearing message
}

Response 201:
{
  "messageId":  "m_017f...",
  "createdAt":  "2026-08-11T09:00:00Z",
  "expiresAt":  "2026-08-12T09:00:00Z"  // server-authoritative
}
GET /v1/sync?cursor=… — per-device catch-up pull
GET /v1/sync?deviceId=d_9f...&cursor=2026-08-11T09:00:00Z

Response 200:
{
  "events": [
    { "type": "message.new",    "messageId": "m_018...", "expiresAt": "..." },
    { "type": "message.delete", "messageId": "m_017f...", "reason": "expired" }
  ],
  "nextCursor": "2026-08-11T09:04:31Z"
}

Keeping the TTL Service and Sync Service as independently deployable, independently scalable microservices with clear event-driven contracts (rather than synchronous request chains) is what allows the deletion pipeline to absorb bursty load without back-pressuring the user-facing send path.

💬
What the interviewer may ask
  • “Would you make expiry registration synchronous or asynchronous relative to the message send API call, and why?”
  • “What does the ‘cancel expiry’ operation need to handle if a user deletes a message manually before its timer fires?”
14

Design Patterns & Anti-Patterns

14.1 Patterns worth using

Pattern

Transactional Outbox

Guarantees expiry registration never gets silently lost relative to message creation. If the message exists, its expiry registration will eventually exist too — no silently non-expiring messages after a partial failure.

Pattern

Event-Driven Fan-out

Decouples “detecting expiry” from “notifying every device,” enabling independent scaling and retries, and letting each subsystem operate at its own natural pace.

Pattern

Timing Wheel / Bucketed Index

Converts expensive full-table scans into cheap, bounded per-minute operations. A single background cursor advances one bucket per minute, touching only the messages actually due.

Pattern

Idempotent Consumer

Makes at-least-once delivery safe for both deletion execution and client-side purge application. A duplicate delivery is a no-op, not a bug.

Pattern

Reconciliation / Self-Healing Sweep

A backstop process independent of the primary path, catching silent failures. Its steady-state hit rate should trend to zero; a non-zero rate signals the primary path is systematically leaking.

Pattern

Cryptographic Erasure

Destroying the per-message encryption key as an additional, fast, storage-independent deletion guarantee. Ciphertext left behind in backups or logs is unrecoverable without the key.

14.2 Anti-patterns to avoid

Anti-patternWhy it’s dangerous
Relying solely on client-side timers as the enforcement mechanismClients can be offline, buggy, or malicious — enforcement must be server-driven
Full-table scan expiry sweeps against the live primary storeCatastrophic at scale: read amplification, lock contention, replication lag, especially during expiry storms
Coupling expiry registration synchronously into the hot send path without an outboxRisks silently non-expiring messages on partial failure — a broken privacy promise no user will ever notice until it’s too late
Treating cache-native TTL as sufficient without explicit invalidation from the authoritative deletion eventCache eviction can drift from server truth, leaving expired content briefly readable through the cache
Logging message content or ID-content pairs in verbose debug logsRecreates the very data the feature is meant to eliminate, in a long-retention log system
Ignoring backup/restore flowsA common blind spot where “deleted” content quietly survives in a backup snapshot and resurrects on restore
💬
What the interviewer may ask
  • “What’s the single biggest anti-pattern you’ve seen in naive disappearing-message implementations, and what breaks because of it?”
  • “Why is the transactional outbox pattern specifically well-suited to this problem?”
15

Best Practices & Common Mistakes

15.1 Best practices

  • Treat the server-issued expires_at as the single source of truth; never trust client-computed expiry.
  • Design the sync log as an ordered, replayable event stream so offline devices can catch up correctly regardless of how long they were disconnected.
  • Build the reconciliation sweep from day one, not as an afterthought — it’s the safety net that catches everything else.
  • Exclude or specially handle disappearing messages in backup/export/legal-hold flows explicitly, rather than assuming they’ll “just work.”
  • Instrument expiry-to-deletion latency as a first-class SLO, not just a nice-to-have metric.
  • Purge every local surface on the client (DB, cache, search index, notification tray, chat preview) — not just the primary message table.

15.2 Common mistakes

  • Forgetting that “delivered” and “read” are different events, leading to broken read-based TTL semantics for offline recipients.
  • Under-provisioning reaper worker capacity for predictable expiry storms (e.g., everyone’s 24-hour timers firing around the same local time of day).
  • Assuming a single “delete” call server-side is sufficient without confirming client-side purge actually completed (no ACK tracking).
  • Neglecting clock-skew handling, leading to inconsistent countdown displays across a user’s own devices.
  • Not testing the multi-device offline-for-weeks scenario, which is exactly where sync-log correctness bugs hide.

15.3 A testing strategy worth adopting

Because the feature’s failure modes are largely invisible in normal manual QA — a message that fails to expire looks, to a casual tester, exactly like a message that’s simply still within its TTL window — testing has to be deliberately adversarial rather than happy-path. A solid strategy includes: chaos-testing the reaper fleet by killing workers mid-batch and confirming the reconciliation sweep recovers cleanly; simulating a device offline for weeks and asserting its eventual sync produces zero expired messages in the final local state; fuzzing clock skew on client devices to confirm the server-authoritative expires_at is what actually governs deletion regardless of what the local clock claims; and running load tests that specifically target predictable expiry-storm windows (many messages created at the same wall-clock time with the same popular TTL) rather than only testing steady-state average load. Teams that only test the send path and assume the delete path “just works because it’s symmetrical” are the ones most likely to ship the silent-failure bugs this feature is uniquely prone to.

💬
What the interviewer may ask
  • “What’s the most common mistake teams make when first building this feature, and how would you catch it in code review?”
  • “How would you test the ‘device offline for two weeks’ scenario before shipping?”
  • “Why is this feature especially prone to silent failures that normal manual QA won’t catch, and how does your test strategy compensate?”
16

Real-World Industry Examples

Signal

Protocol-Level Ephemerality

Signal treats disappearing messages as a core protocol feature rather than a UI overlay, with per-conversation timers configurable down to seconds. Because Signal’s entire architecture is end-to-end encrypted by design, message content is never in server plaintext form regardless of TTL — the disappearing-messages feature there is primarily about minimizing on-device retention across all of a user’s linked devices, which makes Signal a strong real-world example of prioritizing the “cross-device consistency” half of this problem.

WhatsApp

Tiered TTL at Massive Scale

WhatsApp offers 24-hour, 7-day, and 90-day disappearing message windows, applied at the chat level by default (with per-message override introduced later). Operating at billions of messages per day, WhatsApp’s expiry pipeline is a strong real-world case study in the bucketed-scheduling and batched-deletion techniques discussed in the Performance section — sweeping such volume with naive per-row table scans would be operationally infeasible.

Telegram

Secret Chats and Self-Destruct Timers

Telegram’s Secret Chats (device-to-device, not stored server-side in the same way as cloud chats) introduced self-destructing messages early on, with the interesting architectural nuance that Secret Chats are deliberately not synced across all of a user’s devices by design — a different (and arguably simpler) point in the design space than WhatsApp’s or Signal’s multi-device sync approach, illustrating that “cross-device consistency” is itself a product decision, not just an engineering given.

Snapchat

View-Based Ephemerality

Snapchat’s original disappearing-content model is read-based rather than send-based — content is deleted upon being viewed rather than after a fixed wall-clock window from send time — making it the canonical real-world example of the “read-based TTL” semantics discussed in section 3.4, including the added complexity of screenshot detection as a social (not technical) deterrent.

📌
Note on sources

The public-facing feature descriptions above (timer durations, general architecture philosophy) reflect these companies’ publicly documented product behavior as of their respective feature launches. Treat specifics as illustrative of the design space rather than as a guaranteed up-to-date technical specification — double-check anything you plan to cite.

💬
What the interviewer may ask
  • “Compare Signal’s and Telegram’s approach to multi-device sync for ephemeral messages — what trade-off does each make?”
  • “Why might a platform choose read-based TTL (Snapchat-style) over send-based TTL (WhatsApp-style) for certain content types?”
17

Frequently Asked Questions

Q1

Can a disappearing message ever truly be un-recoverable, given screenshots and second devices exist?

No system can prevent someone from photographing their own screen with another device — that’s a fundamental, unclosable gap outside the server’s control. The engineering guarantee is scoped to the platform’s own storage and sync surfaces: primary database, replicas, caches, and every registered device’s local copy. Honest products communicate this limitation rather than overselling the guarantee.

Q2

Why not just rely on the database’s built-in TTL/auto-expiry feature and call it done?

Native database TTL solves server-side row removal, but says nothing about notifying connected client devices to purge their own local caches, search indexes, or notification previews. Without an explicit sync-fanout mechanism, a message can be “deleted” server-side while still fully visible on a device that already cached it.

Q3

What happens if two devices open a read-based-TTL message at nearly the same instant?

The server needs a race-free, authoritative rule — typically “first read event received by the server starts the clock, and that decision is broadcast to all devices,” rather than each device independently deciding it was “first.”

Q4

How do you handle a user exporting their chat history — should disappearing messages be included?

This is a deliberate product and legal decision, not just an engineering default. Common approaches are excluding disappearing messages from exports entirely, or applying the same expiry check at export-read time so an export taken before expiry can’t be used to resurrect content after expiry.

Q5

Does turning on disappearing messages reduce server storage costs meaningfully?

At scale, yes — it bounds the working set of “live” message data, which helps control primary storage growth, replication volume, and backup size, even though it adds background compute cost for the expiry pipeline itself. The net effect is usually a favorable trade at high message volumes.

Q6

If a message is deleted server-side but a device never comes back online, is the guarantee ever really broken?

Not in the sense that matters for the platform’s promise: the message is gone from every server-side store the moment it expires, and the offline device simply has a stale local cache it hasn’t yet been told to clear. The guarantee still applies the instant that device reconnects, because the sync log always applies pending deletions before surfacing any newer content, so the device is never allowed to display something the server considers already gone.

Q7

How would you extend this design to support a user changing their mind and disabling disappearing messages mid-conversation?

Toggling the setting only affects messages sent after the change — it must never retroactively alter the expiry of messages already in flight, since that would break the promise each message was sent under. Implementation-wise, this means the TTL configuration is captured per message at creation time from whatever the conversation’s setting was at that instant, rather than being looked up dynamically at expiry-check time from the conversation’s current setting.

18

Summary & Key Takeaways

📌
The core insight

Disappearing messages are a distributed-systems problem disguised as a UI feature. The hard part isn’t showing a countdown timer — it’s guaranteeing that a message stops existing everywhere it was ever copied: the primary database, its replicas, the cache layer, every registered device’s local storage, notification previews, search indexes, and backups.

The architecture that makes this tractable at scale rests on a handful of core ideas: separating expiry detection from deletion execution via a durable queue; using a bucketed timing-wheel index instead of full-table scans to detect expiry cheaply; treating the server’s expires_at timestamp as the single source of truth rather than trusting client-side timers; actively pushing deletion instructions to every device through an ordered, resumable sync log rather than passively hoping clients self-enforce; and running an independent reconciliation sweep as a self-healing backstop against silent failures anywhere in the primary pipeline.

Security-conscious implementations go further with cryptographic erasure, careful handling of backups and legal exports, and disciplined logging practices that avoid recreating the very data the feature exists to eliminate. And across every layer, engineers must be explicit about the feature’s real boundary: it enforces deletion within the platform’s own storage and sync surfaces, but cannot and does not prevent someone from capturing content with an independent device — a limitation to communicate honestly, not paper over.

18.1 Key takeaways to carry into an interview

  • Separate detection from execution via a durable queue — each half can be tuned, retried, and scaled independently.
  • Use a bucketed timing-wheel index, not a full-table scan, to keep expiry cost bounded even at billions of messages per day.
  • Treat the server-issued expires_at as the single source of truth; local clocks are cosmetic.
  • Actively push deletion via an ordered, resumable sync log; never rely on a client to enforce a timer on its own.
  • Run an independent reconciliation sweep as the backstop that catches everything the primary path missed.
  • Consider cryptographic erasure when the storage engine cannot guarantee prompt physical overwrite of deleted bytes.
  • Be honest with users about the screenshot / second-device gap — the guarantee ends at the platform’s own surfaces.

18.2 The one idea to remember

Done well, disappearing messages become invisible infrastructure: users simply trust that what they were promised would vanish, actually vanishes — everywhere, on schedule, every time. The whole point of the design is that the correct outcome is the boring outcome.