Checkpointing
A restarted job has to resume from somewhere. A checkpoint is correct only if it records the input position and the computed state together, in one atomic action — otherwise it is a dual write wearing a reliability hat.
Who needs this, what one row is, and why the obvious build breaks
Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.
A stateful streaming job is killed mid-window and restarted. Where does it resume, and what has to have been saved for the answer to be correct?
The table or topic the job writes into, and everyone reading it: a sessionised events table, a rolling aggregate a dashboard polls, a feature store a model reads at inference time (Feature Pipelines). They experience a checkpoint failure not as an outage but as a gap or a bump in the output — a window that reports half its events, or an hour counted twice.
A checkpoint covers all input consumed up to a position, and all state derived from it. That pairing is the unit; splitting it is the entire failure mode of this lesson. In a partitioned source the position is a set — one offset per partition — and a checkpoint that captures some partitions and not others is not a checkpoint of anything (Topics and Partitions).
Commit the input offset when the record has been processed, and periodically snapshot the in-memory state to durable storage on a timer. Both halves are individually sensible, both are what the client libraries make easy, and a job written this way runs correctly for months — because it only misbehaves when it is killed in the gap between the two, and most restarts are graceful (Graceful Shutdown: The 502 Spike Nobody Investigates).
The state snapshot is written at 10:04 and the offsets are committed at 10:05. The process dies at 10:04:30. On restart the job loads state that already includes records 900–1000 and resumes reading from record 900, so those hundred records are folded into the aggregate twice (Duplicate Rows).
- The state snapshot is written at 10:04 and the offsets are committed at 10:05. The process dies at 10:04:30. On restart the job loads state that already includes records 900–1000 and resumes reading from record 900, so those hundred records are folded into the aggregate twice (Duplicate Rows).
- Reverse the order and the failure reverses with it: offsets committed first, state snapshot second, a crash between them, and now the job resumes past records whose contribution to the state was never saved. The output is silently missing them and no count anywhere is wrong enough to notice (Missing Rows).
- The job checkpoints its own state but the sink it writes to has no idea checkpoints exist. On restart it replays four minutes of output into a table that appends, so the aggregate is inflated exactly once per restart forever (Upserts and Merges).
- The checkpoint interval is set to something comfortable and the state has grown to the point where writing it takes longer than the interval. Checkpoints start overlapping, then failing, then being skipped, and the job is now running with an increasingly old recovery point that nobody is monitoring.
- A checkpoint is taken while some source partitions are further ahead than others. Recovery restores a state that reflects a moment no single partition ever passed through, and windows near the boundary are computed against a mixture (Watermarks).
- The batch equivalent: a job records "processed up to
updated_at = X" in one table and writes its output to another, without a transaction across them. It is the same bug at a twelve-hour cadence, and it is more common than the streaming version (The High-Water Mark).
What is actually happening
- Every resumable computation has two pieces of durable identity: where it has read to and what it has accumulated. Recovery is correct exactly when those two describe the same instant. Everything in this lesson follows from that one sentence.
- A checkpoint is therefore not a backup of state. It is a *consistent cut*: a marker that says "state S is precisely the result of consuming input up to position P". Restoring S and resuming from P reproduces the computation; restoring S and resuming from anything else does not (Stateful Stream Processing).
- Writing S and P as two separate durable operations is the dual-write problem in its purest form. There is no ordering of two independent writes that survives a crash between them; you get duplicates in one order and losses in the other, and the choice is only about which failure you prefer (The Dual Write Problem).
- The way out is the same as everywhere else the dual-write problem appears: make it one write. Put the offsets *inside* the state, and commit the whole thing atomically — one object, one transaction, one conditional write. Stream processors do this by embedding source positions in the checkpoint they snapshot; batch jobs do it by writing the watermark in the same transaction as the output (The Transactional Outbox).
- Distributed state adds a coordination step but not a new idea. Each worker holds part of the state and part of the input position, so the framework injects a marker into the stream, every worker snapshots when it sees the marker, and the checkpoint is complete only when all of them have acknowledged. A partial set of worker snapshots is discarded rather than used (Stages and Tasks).
- None of this makes the output safe. A checkpoint guarantees that the job's internal recovery is consistent; the rows it already emitted between the last checkpoint and the crash are still out there. Making the composition correct requires the sink to be idempotent or transactional, which is precisely what "effectively-once processing" means and precisely what it assumes (Exactly-Once: Input Consumption, State Update, Output Write).
A checkpoint is a consistent cut, not a state backup
The word "checkpoint" invites the wrong mental model. It sounds like saving a file — take what is in memory, write it somewhere durable, carry on. Under that model the input position is a separate bookkeeping concern that lives with the source, and saving the two independently seems entirely reasonable.
The correct model is that a checkpoint is a claim about a relationship: this state is exactly what you get by consuming this input up to this position. The claim is only true if both halves were captured at the same instant, and the only way to make that durable is to write them as one thing.
The diagram below is the whole lesson in one picture. Follow the two arrows into the checkpoint store and notice that they converge before they reach it. Every design where they arrive as two separate writes has a crash window between them, and the size of that window is not the point — its existence is.
- The position is a set, not a number. One offset per source partition; a checkpoint missing any of them describes no reachable state.
- The state is everything derived from consumed input — open windows, keyed aggregates, join buffers, session state, deduplication sets. Anything held in memory that affects future output belongs in it (Streaming State).
- Emitted output is not in the checkpoint. Rows already written to the sink survive the restart and will be written again during replay, which is why sink idempotency is a separate and mandatory concern.
- Checkpoint storage must outlive the node. Local disk covers a process crash and not a machine loss, which is the failure most likely to need it.
- Several checkpoints, not one. The most recent one can be corrupt or incomplete; the fallback is only available if it was retained.
Two writes, two failures, no third option
Once the position and the state are written separately, a crash between them produces one of exactly two outcomes, and which one you get is decided entirely by the order you chose. Neither is correct and neither can be made correct by choosing more carefully, retrying harder, or shrinking the gap.
State first, then position: the saved state includes work whose input the resumed job will read again. Everything in the gap is counted twice. State second, position first: the resumed job skips input whose effect on the state was never saved. Everything in the gap vanishes.
Teams usually pick "position last" because losing data feels worse than duplicating it, then discover that in an aggregate the two are indistinguishable from the outside — a doubled contribution and a missing one both just look like a number. The fix is not a better ordering. It is to stop having two writes.
The pseudocode below is deliberately framework-free, because the shape is what transfers. The safe version has one durable write per interval and reconstructs everything from it; the unsafe version has two and hopes.
A timer writes the in-memory state to durable storage; the consumer commits its offsets on its own schedule. Two mechanisms, two cadences, two storage systems, no relationship between them beyond both being roughly current.
The source positions are a field of the checkpointed state. One conditional write, or one transaction, makes the pair durable. Recovery loads the object and derives the resume position from it, so there is no way for the two to disagree.
Two independent durable writes have a crash window between them by construction, and no ordering removes it — it only chooses whether the failure appears as duplication or as loss. Making the pair a single write eliminates the window rather than narrowing it, which is the same reason the transactional outbox exists on the operational side (The Transactional Outbox).
1UNSAFE — two independent durable writes2--------------------------------------3loop:4 batch = source.read(from = position)5 state = fold(state, batch)6 position = batch.end_position7 8 every checkpoint_interval:9 checkpoint_store.put(state) # write 110 source.commit_offsets(position) # write 211 # crash between write 1 and write 2 -> gap is reprocessed into12 # state that already contains it -> double counting13 # swap the two lines and a crash between them -> gap is skipped14 # entirely and its contribution is lost, silently15 16SAFE — one durable write containing both17----------------------------------------18loop:19 batch = source.read(from = checkpoint.position)20 state = fold(state, batch)21 22 every checkpoint_interval:23 checkpoint_store.put_atomic({24 state: state,25 position: batch.end_position, # same object, same write26 version: logic_version, # so a restore knows what it means27 })28 29on restart:30 checkpoint = checkpoint_store.latest_complete()31 state = checkpoint.state32 resume_from(checkpoint.position)33 # input between checkpoint.position and the crash is read AGAIN.34 # that is at-least-once consumption, and it is fine ONLY because35 # the sink is keyed and idempotent:36 # MERGE INTO agg USING new ON agg.key = new.key37 # WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ...The safe version does not eliminate reprocessing — it makes reprocessing harmless. That is the actual goal, and it is why the sink appears in a lesson about checkpoints: a consistent checkpoint plus a non-idempotent sink is still a broken pipeline, just one that fails on the output side instead of the state side.
What a restart looks like from the outside
The timeline below traces a job aggregating five-minute windows through a crash. It is a teaching timeline, not a measurement: the clock labels exist to make the replay interval concrete.
The important column is landsIn. Events e3 and e4 were consumed before the crash and are already folded into the in-memory state, but the last checkpoint predates them — so on restart they are read a second time. With a checkpoint that embedded its position, the restored state does not contain them and the second read is correct. With separately committed offsets, the restored state may already contain them, and the second read double counts.
Notice also what the sink sees. The 10:00–10:05 window was emitted at 10:05:10 before the crash. After recovery it is emitted again with the same key, and whether that is a correction or a duplicate is decided by the sink, not by the checkpoint. An idempotent merge on the window key turns the replay into a rewrite of an identical row; an append turns it into a permanent double.
| Event | Happened | Arrived | Lands in |
|---|---|---|---|
| e1 | 10:01 | 10:01 | W1 — consumed and covered by checkpoint C1 C1 taken at 10:03 with position after e2. |
| e2 | 10:02 | 10:03 | W1 — consumed and covered by checkpoint C1 |
| e3 | 10:04 | 10:04 | W1 — consumed, NOT in any checkpoint, replayed after restart |
| e4 | 10:06 | 10:06 | W2 — consumed, NOT in any checkpoint, replayed after restart |
| e5 | 10:07 | 10:09 | W2 — arrives after the restart, read once |
The replay interval is exactly the span between the last complete checkpoint and the crash — here 10:03 to 10:06. Shortening the checkpoint interval shrinks that span and nothing else; it does not make the replay safe. Only the sink can do that.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Crash between the state write and the offset commit. | An aggregate that is slightly too high, or slightly too low, for one interval — with no error anywhere. | Two independent durable writes with a window between them. | Embed the position in the checkpointed state and commit once. Nothing else fixes this (The Dual Write Problem). |
| State grows past what fits in the checkpoint interval. | Checkpoint duration rising, then checkpoints being skipped, then a very long recovery when the job eventually restarts. | A keyspace with no eviction, or windows that never close because a watermark never advances. | Put TTLs on keyed state and alert on checkpoint duration as a fraction of the interval, not on failure (Streaming State). |
| Restart resumes from a position that has aged out of source retention. | The job starts from the earliest available offset and a period of input is simply absent from the output. | Retention was sized for normal lag, not for worst-case downtime. | Size retention from maximum tolerable outage and alert when lag approaches a fraction of it (Retention and Replay). |
| Job code is redeployed and restored from an existing checkpoint. | Deserialization error, or worse, a successful restore where old state is reinterpreted under new logic. | Checkpointed state has a schema and the deploy changed it. | Version the state, refuse to restore across incompatible versions, and treat a logic change as a deliberate reprocess (Reprocessing vs Retrying). |
| Parallelism increased to clear a backlog. | The job will not start, or starts with state assigned to the wrong workers. | Keyed state is partitioned by the old worker count. | Rescale only where the engine supports redistributable state groups; otherwise rebuild state from a replay (Consumer Groups and the Parallelism Ceiling). |
| Recovery tested only via graceful shutdown in staging. | Recovery works perfectly in every test and duplicates data the first time a node is lost. | A graceful stop performs both writes; the bug only exists when one of them is missing. | Test by killing the process under load, and assert on output counts rather than on the job coming back up (Graceful Shutdown: The 502 Spike Nobody Investigates). |
How to build it
Most important first.
- Store the input position inside the checkpointed state and commit them as one atomic unit. If your framework does this for you, verify it rather than assume it; if it does not, this is the one piece of machinery worth writing by hand.
- Make the sink idempotent over a deterministic key, so that replaying the interval between the last checkpoint and the crash is harmless. Checkpoint frequency then becomes a performance decision instead of a correctness one (Idempotent Data Pipelines).
- Choose the checkpoint interval from the recovery objective, not from a default: the interval bounds how much work is redone after a crash, and therefore how long recovery takes and how much duplicate output the sink must absorb.
- Bound and monitor state size explicitly — TTLs on keyed state, closing windows, evicting sessions. Unbounded state does not fail as an error; it fails as checkpoints that get slower until they stop completing (Streaming State).
- Keep the source retention comfortably longer than the maximum time you could be running on an old checkpoint. A recovery position that has fallen out of retention is not a slow recovery, it is a permanent gap (Retention and Replay).
- For batch, put the watermark update in the same transaction as the output write, or derive the watermark from the output itself —
SELECT MAX(order_date) FROM fct_ordersneeds no second store and cannot disagree with the data (The High-Water Mark). - Test recovery by actually killing the job under load, not by stopping it gracefully. A graceful stop exercises the path where both writes happen; the bug lives in the path where only one does (Graceful Shutdown: The 502 Spike Nobody Investigates).
What this actually promises
Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.
- A checkpoint that atomically contains position and state guarantees that recovery reproduces a state the computation genuinely passed through. It does not guarantee that no output was emitted after that state — output that already left the job is not in the checkpoint.
- Consumption is at-least-once from the checkpoint forward: everything between the checkpoint and the crash is read again. Nothing downgrades that to once except an idempotent or transactional sink.
- A distributed checkpoint guarantees a consistent cut *across workers*, meaning no worker's state includes an input another worker's position excludes. It does not guarantee alignment with wall-clock time or with event time.
- Checkpoint durability inherits the storage it is written to. A checkpoint on ephemeral local disk is a performance optimisation, not a recovery mechanism, and the distinction only surfaces when the node itself is what died.
- Nothing here promises freshness during recovery. A job resuming from an old position is behind, and it will stay behind until it catches up — during which the output is both incomplete and confidently present (The Backlog Arithmetic: Four Levers and a Drain Time).
Can I trust it?
A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.
- The check that catches this class directly: for a closed period, count output records per key and compare with the count of distinct input records for the same period. A checkpoint bug produces either an excess or a deficit, and the sign tells you which of the two orderings you implemented.
- A cheaper continuous version: assert that the job's committed position never moves backwards other than during an intentional replay, and that the gap between position and source head stays bounded (Offsets and Commits).
- What both miss: a checkpoint that is consistent but stale, where everything reconciles and everything is late. And neither says anything about state that was never supposed to be there — an unbounded keyspace reconciles perfectly right up until the job stops checkpointing at all.
- The checkpoint interval sets the floor on recovery time: after a crash the job must reprocess everything since the last checkpoint before it produces anything new, so the freshness dip is at least that large and usually larger.
- Shorter intervals mean faster recovery and more overhead per interval — more state writes, more metadata operations, and in file-based sinks more small files (File Size and the Small-Files Problem).
- During catch-up the output is fresh in the sense that rows are being written and stale in the sense that they describe old input. A freshness metric derived from write time reports health throughout the entire incident, which is the reason freshness must be measured on event or completion time instead (The Freshness SLO).
- Checkpointed state has a schema, and changing the job's aggregation usually changes it. Restoring a new job version from an old checkpoint is a deserialization problem: it either fails loudly, or — worse — succeeds and interprets old bytes under new semantics (Backward Compatibility).
- Changing parallelism redistributes keyed state across workers. Frameworks that support rescaling do so by partitioning state into reassignable groups; changing partitioning without that support means the state cannot be restored at all and the job must start from a replay (Partitions: the Unit of Parallelism).
- A deliberate state reset — because the logic changed — is a reprocessing decision, not a restart. It needs a source position chosen from the data, an output range to replace, and the same publish discipline as any backfill (Reprocessing vs Retrying).
- The normal path: load the latest complete checkpoint, resume from the positions it contains, absorb the duplicate output in an idempotent sink, catch up.
- The bad path: the latest checkpoint is corrupt or incomplete, so you fall back to an older one, which reprocesses more, which produces more duplicate output. Keeping several recent checkpoints rather than one is what makes that fallback available.
- The last resort: no usable checkpoint, so the job rebuilds state by replaying the source from a chosen position. That works only if the retention window still covers the period the state depends on, which for a long-window aggregation can be much further back than the last checkpoint (Replay from the Log).
What can go wrong
- Position and state committed separately, with a crash in the gap. The defining failure of the lesson, and it is invisible until the crash happens.
- Checkpoint duration exceeding the checkpoint interval, so checkpoints queue, overlap and eventually stop completing while the job continues to appear healthy.
- State growing without bound because a keyspace has no eviction, turning a fast job into one that spends most of its time writing state (Streaming State).
- Checkpoints written to storage that does not outlive the node, so a machine failure — the case checkpoints exist for — is the one case they do not cover.
- Source retention shorter than the worst-case recovery lag, converting a recoverable outage into permanent data loss (Retention and Replay).
- An idempotent sink whose key is not actually stable across replays — derived from processing time, a random id, or an auto-increment — so the deduplication that was supposed to absorb the replay does nothing (Deduplication).
- Recovery tested only by graceful shutdown, so the failure path the mechanism exists for is the one path never exercised.
- "We commit offsets after processing, so we cannot lose data." True, and it means you can duplicate data, because the alternative failure has to go somewhere. The choice of commit order picks which failure you get, never whether you get one (At-Least-Once Delivery).
- "The framework handles checkpointing." It handles the job's internal state. Whether the composition of job and sink is correct depends on the sink, and the framework has no opinion about a table you append to (Exactly-Once: Input Consumption, State Update, Output Write).
- "Checkpoints are backups." A backup is for restoring data you lost. A checkpoint is for resuming a computation, and restoring one to a job whose logic has changed produces state that means something different from what it meant when it was written.
- "The job restarted and caught up, so nothing happened." Something happened to the output for the duration of the catch-up, and if the sink is not idempotent something happened to it permanently.
- "Batch jobs do not need this." A high-water-mark table is a checkpoint, and updating it outside the transaction that writes the output is the same dual write at a slower cadence (Incremental Processing).
Operating it
- Time since the last *successful* checkpoint, and checkpoint duration as a distribution rather than an average. Rising duration is the leading indicator of every state-size problem (Pipeline Metrics).
- Committed position versus source head, per partition. One partition lagging while the rest keep up is a different incident from all of them lagging together (The Backlog Arithmetic: Four Levers and a Drain Time).
- State size per worker, trended. Skewed state is skewed checkpoint time, and one slow worker delays the whole consistent cut (Data Skew).
- Restart count with the recovery position each restart resumed from — the pair that turns "the job restarted overnight" into "the job reprocessed forty minutes twice" (Pipeline Observability).
- At 10x throughput the state usually grows with the keyspace rather than with the record rate, so the constraint is cardinality, not volume. A job aggregating by customer scales differently from one aggregating by country (Partition Cardinality).
- At 100x, checkpointing whole state per interval stops being viable and incremental snapshots become mandatory — which introduces its own compaction and cleanup problems, because incremental checkpoints reference earlier ones.
- More workers make the consistent cut more expensive to coordinate: the checkpoint completes at the speed of the slowest participant, so straggler behaviour shows up as checkpoint latency long before it shows up as throughput (Straggler Tasks).
- Checkpointing costs a write of the state per interval, so the driver is state size multiplied by frequency. Incremental checkpointing changes the multiplier to changed-state size, which is the reason it exists.
- Recovery costs reprocessing the interval since the last checkpoint, paid in compute and in whatever the sink charges for absorbing duplicate writes.
- Retention on the source is a recovery-window cost: bytes held continuously to make an occasional replay possible. Argue it as an insurance decision rather than a storage one (Retention and Replay).
- The interval is a direct dial between two costs — steady-state overhead and post-crash reprocessing — and the right setting depends on how often the job actually crashes, which is a number most teams have and never look at.
- Frequent checkpoints buy short recovery and cost steady-state throughput. Infrequent ones buy throughput and cost a long, duplicate-heavy recovery. There is no setting that is good at both, and the honest procedure is to pick from a stated recovery objective.
- Embedding positions in state is strictly more correct and strictly less convenient: it couples the job to its source in a way that makes changing the source topology a state-migration exercise.
- Idempotent sinks make checkpoint frequency a tuning knob rather than a correctness knob, and they cost a read or a merge per write instead of an append (Upserts and Merges).
Where this applies
Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.
- GENERALThe requirement that position and state be committed together is a consequence of crash semantics, not of any framework. It applies identically to a Flink job, a consumer loop written by hand, and a nightly SQL job that stores a watermark in a control table.
- ENGINE-SPECIFICStream processors differ in whether checkpoints are aligned or unaligned, whether they are incremental, whether state can be rescaled, and whether the source position is embedded in the snapshot. Those four answers determine recovery time, tuning surface and whether a parallelism change is a config edit or a migration.
- BROKER-SPECIFICWhere the consumed position lives differs: brokers that store committed offsets server-side per consumer group make the naive two-write design very easy to reach for, while systems where the consumer owns its position make embedding it in the checkpoint the path of least resistance. The correctness argument is the same; the default you fall into is not.
Where the depth lives
This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.
- — Distributed Systems owns the consistent-snapshot algorithms this is built on — how a marker injected into a stream produces a global cut without stopping the world, and what that cut does and does not say about causality.
- — DevOps / Production Engineering owns the recovery objective this tunes against: how much reprocessing after a crash is acceptable is the same question as a recovery point objective, asked about a computation instead of a database.