A stream processor that crashes will reprocess some records. There is no way around it: the work done between the last durable checkpoint and the crash is lost, and the only way to recover it is to read those records again. Exactly-once stream processing does not prevent that replay. It makes the replay harmless, so that the state the job keeps and the output it publishes reflect every input record once.

This article is about the architecture that achieves that end to end. It treats exactly-once as a chain of three links: a source that can rewind, a snapshot that captures state and source position together, and an output protocol that ties visibility to that snapshot. We look at four ways to build the last link, map the same chain onto Flink, Kafka Streams and Spark, inject a crash at every step of a worked pipeline, and list what still cannot be made exactly-once.

Exactly-once is a chain: replayable source, consistent snapshot, committed outputKafka sourceoffsets kept in snapshotKeyed operatorstate in RocksDBWindow operatortimers + accumulatorsTransactional sinkpre-commit per checkpointrecords + barrier nCheckpoint coordinatorinject barrier n, collect acks, notify completetriggerackcomplete: commitSnapshot storeS3 / HDFS, incrementalasync uploadKafka output topiccommit markersConsumerread_committedAlternative sinksidempotent upsert, offsets-in-sink, staged filesProcessing may run twice after a failure; the effects on state and output appear once.
Exactly-once as a chain: source offsets and operator state are snapshotted together, and the sink commits output only when that snapshot completes.

What exactly-once actually guarantees

Be precise about the claim. A record may be read, transformed and even written several times. What is guaranteed is that the committed result is the same as if each record had been processed exactly once: counters are not double-counted, and downstream readers see each output record once. Some people call this effectively-once, which is the more accurate name.

The guarantee covers state the framework manages and outputs that join its protocol. It does not cover an email sent, an HTTP call made or a metric incremented from inside an operator. Those happen whenever the code runs, including during replay, and need their own idempotency.

What exactly-once actually guarantees

Be precise about the claim. A record may be read, transformed and even written several times. What is guaranteed is that the committed result is the same as if each record had been processed exactly once: counters are not double-counted, and downstream readers see each output record once. Some people call this effectively-once, which is the more accurate name.

The guarantee covers state the framework manages and outputs that join its protocol. It does not cover an email sent, an HTTP call made or a metric incremented from inside an operator. Those happen whenever the code runs, including during replay, and need their own idempotency.

The three-link chain

Each link fails differently if it is missing. Without a rewindable source, replay has nothing to read and records are lost. Without a consistent snapshot, recovered state and source position disagree, so some records are counted twice or not at all. Without a committed output, state is right but downstream systems see the duplicate writes made during replay. The guarantee is only as strong as the weakest link, and most production exactly-once bugs are one link quietly configured away.

Link one: a source that can rewind

A Kafka topic is the canonical replayable source: records stay for the retention period and can be read again from any offset. The key architectural rule is that the authoritative position is the offset stored in the processor's own snapshot, not the consumer-group offset committed to Kafka. On recovery the job seeks every partition to the snapshotted offset. Committed group offsets are still useful for lag monitoring, but they are not used for correctness. Retention must comfortably exceed the longest plausible outage plus recovery, or a long incident turns into data loss.

Sources that cannot rewind, such as webhooks, sockets or a message queue that deletes on acknowledgement, cannot join the chain directly. Land them in a durable log first, acknowledging the sender only after the log write succeeds, and let the stream job read from the log. Change data capture follows the same rule: the database's replication log is the replayable source, and the connector's position in it belongs in the snapshot.

Link two: consistent snapshots of state

Flink takes consistent snapshots with checkpoint barriers, a variant of the Chandy-Lamport algorithm. The coordinator asks every source to record its offsets and inject barrier n into its output. Barriers flow with the records. An operator with several inputs waits until barrier n has arrived on all of them, which is called alignment, then snapshots its state and forwards the barrier. Its snapshot therefore reflects exactly the records before the barrier on every input, which matches the recorded offsets. Large state is uploaded asynchronously and, with RocksDB, incrementally.

Alignment is the price. Under backpressure, barriers queue behind slow records and checkpoints take longer. Unaligned checkpoints, enabled with enableUnalignedCheckpoints() on the checkpoint configuration, let barriers overtake buffered records and store those in-flight buffers in the snapshot instead. They keep checkpoints fast under backpressure at the cost of larger snapshots.

Link three: four sink protocols

The output link has four common designs. All of them tie what downstream readers see to a completed snapshot; they differ in how.

Sink protocolHow it worksCostFits
Transactional (two-phase)Open a transaction per checkpoint; pre-commit at the barrier; commit when the checkpoint completesOutput visible only once per checkpoint intervalKafka topics, XA-capable databases
Idempotent upsertDeterministic key per result; replay overwrites with the same valueReaders can briefly see older values during replayKey-value stores, search indexes, JDBC upsert
Offsets-in-sinkWrite results and source offsets in one database transaction; resume from the stored offsetsCouples the job to one databaseSingle relational sink
Staged filesWrite files to a pending location; rename or register them on checkpoint completionFile count and commit latencyData lakes, table formats

For Kafka output, Flink's KafkaSink implements the transactional protocol. Records produced during a checkpoint epoch go into an open Kafka transaction; they are durable on the brokers but invisible to read_committed consumers. At the barrier the sink flushes and records the transaction in the checkpoint. When the coordinator reports the checkpoint complete, the sink commits. On recovery the sink commits any transaction recorded in the last completed checkpoint, which may already have been committed before the crash, and aborts transactions it opened after that point.

env.enableCheckpointing(30_000);                       // checkpoint every 30 s
env.getCheckpointConfig().enableUnalignedCheckpoints(); // optional: fast under backpressure

KafkaSink<String> sink = KafkaSink.<String>builder()
    .setBootstrapServers("broker:9092")
    .setRecordSerializer(KafkaRecordSerializationSchema.builder()
        .setTopic("settlements")
        .setValueSerializationSchema(new SimpleStringSchema())
        .build())
    .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
    .setTransactionalIdPrefix("settlements-v1")          // stable and unique per job
    .setProperty("transaction.timeout.ms", "900000")      // must outlast checkpoint + recovery
    .build();

The timeout line matters. A Kafka producer's transaction.timeout.ms defaults to 60 seconds, and the broker caps it with transaction.max.timeout.ms, 15 minutes by default. The transaction opens at the start of its epoch, so it must stay alive through the checkpoint interval, the checkpoint itself and, in the worst case, a restart. If a checkpoint completes, the job crashes before the sink commits, and recovery takes longer than the transaction timeout, the broker has already aborted the transaction and that epoch's output is lost even though the checkpoint says it was committed.

When the sink is a single relational database, offsets-in-sink is often simpler than any transaction protocol: store the source position in the same transaction as the results.

BEGIN;
UPDATE stream_progress SET batch_id = :batch_id
 WHERE job = 'totals' AND batch_id < :batch_id;
-- 0 rows updated: this batch was already applied, so ROLLBACK and skip it
INSERT INTO merchant_totals (merchant_id, day, total_cents)
VALUES (:m, :d, :delta)
ON CONFLICT (merchant_id, day) DO UPDATE
   SET total_cents = merchant_totals.total_cents + EXCLUDED.total_cents;
COMMIT;

The same chain in Kafka Streams and Spark

Kafka Streams builds the same chain from Kafka alone. With processing.guarantee set to exactly_once_v2, each stream thread writes output records, changelog updates for its state stores and its consumer offsets in one Kafka transaction, so all three commit or abort together. The commit interval drops to 100 ms by default under this setting, which bounds how long output stays invisible.

Spark Structured Streaming checkpoints offsets and state per micro-batch. Its file sink records each batch's files in a metadata log so readers see each batch once. With foreachBatch you write to arbitrary systems, and the guarantee is at-least-once unless you use the batch id the callback receives to skip batches already applied, exactly as the SQL above does.

Worked example: a crash at every step

Take a settlement pipeline: Kafka payments in, per-merchant running totals in keyed state, settlement records out to a Kafka topic, checkpoints every 30 seconds. Checkpoint 41 has completed. During epoch 42 the job reads 120,000 payments. Now crash it at each step and follow the result.

Crash pointRecoveryVisible result
Mid-epoch, before barrier 42Restore checkpoint 41 state and offsets; abort open transaction; replay 120,000 recordsDownstream never saw epoch 42; replayed output commits once
After sink pre-commit, before checkpoint 42 completesCheckpoint 42 incomplete, restore 41; abort pre-committed transactionSame as above
After checkpoint 42 completes, before sink commitsRestore 42; sink commits the transaction recorded in itEpoch 42 appears once, slightly late
After commit, before the next barrierRestore 42; nothing to redoNo duplicates
After checkpoint 42 completes, before commit, and recovery takes 20 minutes with a 15-minute timeoutBroker already aborted the epoch 42 transaction; there is nothing left to commitEpoch 42 output lost: an exactly-once violation

Only the last row breaks the guarantee, and it is a configuration error rather than a protocol flaw. It is also the case to rehearse: kill the job, keep it down past the timeout in staging, and verify you get an alert rather than silently missing settlements.

Side effects outside the protocol

Effects outside the protocol need their own design. Move external calls to the edge of the pipeline: emit an intent record to a committed topic, and have a separate consumer perform the call with an idempotency key derived from the record, such as the payment id. The receiving service deduplicates on that key. This is the same reasoning as the transactional outbox, applied to streams.

Upgrades without breaking the chain

Deployments are planned failures, and the chain must survive them too. In Flink, stop the job with a savepoint rather than cancelling it: stop-with-savepoint drains the pipeline, takes a final snapshot and commits the last sink transaction, so the new version resumes from a clean point. Give every stateful operator an explicit uid so its state can be matched to the new job graph after you add or reorder operators; auto-generated ids change with the topology and the state is then dropped or the restore fails.

Keep the transactional id prefix stable across versions of the same job, and different between jobs, so recovery can find and resolve its own open transactions without touching anyone else's. Treat state schema changes like database migrations: use serializers that support evolution, and test restoring a production savepoint into the new version before the release, not during it.

Failure modes

Failure modeSymptomMitigation
Sink left at at-least-onceDuplicates downstream after every restartAssert the delivery guarantee in a startup check
Consumers on read_uncommittedReaders see aborted dataSet isolation.level=read_committed on every downstream consumer
Changed transactional id prefixLingering open transactions stall read_committed readersKeep the prefix stable; abort orphans when changing it
Non-deterministic processingReplay produces different output than the first runAvoid wall-clock time and random ids; derive from event data
Checkpoints failing repeatedlyOutput stops while input lag growsAlert on time since last completed checkpoint

Operating it

Watch time since the last completed checkpoint, checkpoint duration and size, and the lag between processing and committed output, which with a transactional sink is roughly one checkpoint interval. Because consumers on read_committed cannot read past the oldest open transaction on a partition, a stuck transaction shows up as consumer lag downstream, not as an error in the job.

Add an end-to-end audit for the outputs that matter. For a settlement pipeline, a daily batch job can sum the input topic per merchant and compare it with the committed output; any difference is a broken link somewhere in the chain. Such reconciliation catches configuration drift, such as a sink quietly redeployed at a weaker guarantee, that no single metric would reveal.

Trade-offs

Transactional sinks give the strongest guarantee and the highest output latency. Idempotent upserts are cheaper and faster but expose intermediate values during replay. Offsets-in-sink is simple and tight but ties the job to one store. When every consumer is an upsert keyed on a deterministic id, at-least-once delivery into an idempotent sink gives the same final state for less machinery.

What to do next

  1. Draw your pipeline as source, state, sink and mark which protocol each link uses.
  2. Verify source retention exceeds your worst outage plus recovery time.
  3. Pick a sink protocol per output from the table above and assert it at startup.
  4. Set the transaction timeout above checkpoint interval plus checkpoint duration plus restart time, within the broker maximum.
  5. Move external calls behind a committed topic and idempotency keys.
  6. Rehearse the crash points from the worked example in staging, including an outage longer than the timeout.

Keep learning: Kafka exactly-once semantics, the broker side of Kafka transactions, Flink checkpoint design, Flink savepoints, idempotency and the outbox pattern.

Key takeaway: Exactly-once stream processing means replay is harmless, not that replay never happens. Make the source rewindable, snapshot its position together with operator state, and make output visibility depend on that snapshot through transactions, idempotent keys, offsets stored with results or staged files. Then size the timeouts, keep side effects outside, and rehearse crashes.