Knowledge Guide
HomeSystem DesignScalable Systems (Advanced Topics)

hard Stateful Stream Processing: Checkpoints, Restart & TTL

An operator's state is only as durable as its last checkpoint

A stateful operator — a running sum, a join buffer, a "have I seen this id before" dedup table — keeps memory that spans many events, not just the one it's currently touching. That memory typically lives in the process (an in-memory hash map, maybe backed by local disk). The process is not immortal: it will crash, get rescheduled, or be redeployed. If nothing durable was ever recorded on purpose, a restart hands the operator a blank state while the source keeps delivering (or redelivering) events — an aggregation restarts its running sum from zero and double-counts once combined with replayed input, and a naive in-memory dedup set's "already seen" memory is simply gone, so an event it had already processed once looks brand new the second time and gets processed again. The bug is not in the dedup logic itself — if (seen.contains(id)) return; seen.add(id); is correct — the bug is that seen does not survive a restart unless something explicitly makes it survive.

Timeline of source offsets. Top row shows the original run: a solid segment from offset 0 to 100 ends in a labeled checkpoint that saves state seen equals 1 to 100 and sum S100, then a dashed segment continues to offset 150 where a red X marks a crash, losing the in-memory state for offsets 101 to 150. Bottom row shows the restart: the checkpoint is reloaded restoring state seen equals 1 to 100, then offsets 100 to 150 are replayed reprocessing 50 events until state again equals seen 1 to 150, matching the pre-crash state. A note at the bottom states the sink must be idempotent or transactional so any output already emitted for offsets 100 to 150 before the crash is not double-applied by the replay
Timeline of source offsets. Top row shows the original run: a solid segment from offset 0 to 100 ends in a labeled checkpoint that saves state seen equals 1 to 100 and sum S100, then a dashed segment continues to offset 150 where a red X marks a crash, losing the in-memory state for offsets 101 to 150. Bottom row shows the restart: the checkpoint is reloaded restoring state seen equals 1 to 100, then offsets 100 to 150 are replayed reprocessing 50 events until state again equals seen 1 to 150, matching the pre-crash state. A note at the bottom states the sink must be idempotent or transactional so any output already emitted for offsets 100 to 150 before the crash is not double-applied by the replay

The fix: checkpoint state and input offset together, then replay from there

Periodically — say every 10 seconds — the engine takes a consistent snapshot of two things at once: (1) every operator's state (the serialized dedup set, the running sum, the join buffer) and (2) exactly how far into the source it had read (e.g. the Kafka offset per partition). Both are written to durable storage (S3, HDFS, a distributed filesystem) as one atomic unit. Coordinating this across a whole dataflow graph — many operators, each processing independently — without pausing the pipeline is a distributed-snapshot problem, and it is solved the same way it was solved in 1985: a checkpoint barrier is injected at the sources and flows downstream with the data. Each operator, on receiving a barrier from every one of its inputs, snapshots its own state at that exact point and forwards the barrier onward. Flink's "aligned checkpoints" are a direct implementation of the Chandy-Lamport distributed snapshot algorithm; the result is a globally consistent cut through the whole graph even though nothing stopped processing.

// on restart
state  = loadLatestCompletedCheckpoint().state        // e.g. seen = {1..100}, sum = S100
offset = loadLatestCompletedCheckpoint().sourceOffset  // e.g. 100
resumeConsuming(source, fromOffset = offset)           // replay only 100 -> crash point, not from 0

Traced: checkpoint at offset 100, crash at offset 150

StepWhat happensOperator state after
1Checkpoint fires at offset 100seen={1..100}, sum=S₁₀₀ — durably saved
2Processing continues, offsets 101–150 consumed (not yet checkpointed)in-memory only: seen={1..150}
3Process crashes at offset 150in-memory state for 101–150 is lost
4Restart: load the last completed checkpointseen restored to {1..100}, resume position = offset 101 (next unread)
5Replay offsets 101→150 from the sourcethe same 50 events are reprocessed; seen returns to {1..150} — identical to the state right before the crash

This is why checkpointing offset and state together matters: replaying 101→150 against a dedup set that only contains {1..100} is correct — those 50 ids are genuinely new to this rebuilt state, so they get recorded once. If instead the source offset had been committed independently and ahead of the state checkpoint (a common naive-integration bug), restart would resume past events the state never actually recorded, and the "seen" gap would let real duplicates through.

Checkpointing state alone is not exactly-once — the sink matters too

Suppose the operator, before the crash, had already emitted output downstream for some of offsets 101–150 (a partial aggregate flushed to a database, a dedup'd event forwarded to another topic). After restart, the operator correctly reprocesses 100–150 from its restored state and emits again. If the destination just appends whatever it receives, that output is now applied twice — the checkpoint fixed the operator's internal state but not what already left the building. Two ways to close this last gap:

State TTL: bounding growth without reopening the bug you just fixed

A dedup set keyed by every event id ever seen, or a per-user aggregate keyed by every user ever active, grows forever if nothing ever leaves it — memory usage climbs without bound, and checkpoints (which must serialize the whole state) grow slower and larger over time. The fix is a TTL: expire a key's entry after it has been idle for some duration (e.g. drop a dedup entry 24 hours after it was last seen). But TTL sizing is not free to shrink: if the TTL is shorter than the real gap between an event and its possible duplicate, the bug returns — a genuine retry or redelivery that lands after its original entry has expired sails past the dedup check as "new," gets processed twice, and produces exactly the double-count the state was built to prevent, just delayed instead of immediate.

Traced: dedup entry for id=42 is created at t=0s with TTL=60s, so it expires at t=60s. Upstream retries with exponential backoff and its final retry for id=42 lands at t=65s — five seconds after expiry. The dedup check finds no entry, treats id=42 as new, and processes it a second time.

Pitfalls

The judgment layer

Checkpoint interval: frequent vs infrequent. A short interval (seconds) means a crash only loses/replays a small window — fast recovery, little duplicate reprocessing — at the cost of steady overhead: every checkpoint pauses or throttles the pipeline briefly for barrier alignment and writes state to durable storage repeatedly. A long interval (minutes) has near-zero steady-state overhead but a crash forces a much bigger replay window, meaning slower recovery and a longer stretch of output that must rely on the sink's idempotency to avoid visible duplication. Tune the interval to how expensive replay is versus how expensive the checkpoint itself is, not to a default.

At-least-once + idempotent sink vs. checkpointed exactly-once. If the destination naturally supports idempotent writes (a keyed upsert, a natural unique constraint), the simpler design — checkpoint state for internal correctness, deliver at-least-once, let the idempotent sink absorb any duplicate — avoids the overhead of barrier-aligned, transactional checkpointing entirely. Full checkpointed exactly-once (aligned checkpoints + a two-phase-commit sink, as in Flink+Kafka) gives a stronger guarantee without depending on the destination's semantics, but adds real cost: barrier alignment can stall a fast operator behind a slow one under backpressure (mitigated by Flink's unaligned checkpoints, which snapshot in-flight buffered data too so they don't have to wait for alignment, at the cost of a larger checkpoint). Choose exactly-once machinery only when the sink cannot be made idempotent and duplicates are genuinely unacceptable (billing, financial ledgers); otherwise at-least-once + idempotency is simpler and cheaper.

TTL sizing: memory vs correctness window. A short TTL keeps state small and checkpoints fast but narrows the window in which a legitimate duplicate is still caught — set it below the real tail latency of retries/redeliveries and you silently reopen the double-processing bug. A long TTL is safer for correctness but directly costs memory and checkpoint size, which feeds back into the interval trade-off above. Size TTL to the observed p99+ of "time between an event and its possible duplicate" in your actual system, with margin — not to a round number that feels safe.

Takeaways


Synthesized from the Chandy-Lamport distributed snapshot algorithm (1985), Apache Flink's checkpointing, state-backend and state-TTL documentation, Kafka Streams' changelog-topic state stores, and Kleppmann's Designing Data-Intensive Applications (ch. 11, Stream Processing). Re-authored/Deepened for this guide. See also: Event-Time, Watermarks & Late Data; Idempotency & Exactly-Once Is a Myth; Kafka Internals — Partitions, Offsets & Consumer Groups.

🤖 Don't fully get this? Learn it with Claude

Stuck on Stateful Stream Processing: Checkpoints, Restart & TTL? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Stateful Stream Processing: Checkpoints, Restart & TTL** (System Design) and want to truly understand it. Explain Stateful Stream Processing: Checkpoints, Restart & TTL from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Stateful Stream Processing: Checkpoints, Restart & TTL** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Stateful Stream Processing: Checkpoints, Restart & TTL** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Stateful Stream Processing: Checkpoints, Restart & TTL** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes