Exactly-Once and Delivery Guarantees
"Exactly-once" is streaming's most abused phrase — simultaneously a real, shippable feature and a physical impossibility, depending entirely on which claim is being made. This page separates the claims: what the three delivery levels actually promise, what Kafka's transactions do and don't cover, and why the durable engineering answer is idempotency at the edges.
The three levels, stated precisely
- At-most-once: fire and forget — failures lose messages, never duplicate them. Fine for metrics ticks and telemetry where a gap is cheaper than machinery.
- At-least-once: retry until acknowledged — failures duplicate messages, never lose them. The practical default of every serious system (Kafka, RabbitMQ, JetStream, SQS).
- Exactly-once: each message takes effect precisely once. The honest phrasing is already the lesson: delivery exactly-once is impossible in an asynchronous network (an unacknowledged send is indistinguishable from a lost one — the two-generals core); processing effects exactly-once is achievable within a transactional boundary.
So the engineering question is never "is delivery exactly-once?" but "is the effect idempotent or transactional?"
Kafka's machinery: idempotent producers and transactions
Two mechanisms, often conflated:
- Idempotent producer (default since Kafka 3.0): producer IDs + per-partition sequence numbers let brokers discard retry-duplicates. Kills the classic duplicate source — producer retries — at zero design cost. Scope: one producer session, within the cluster.
- Transactions: a producer atomically writes to multiple partitions and commits consumer offsets in the same transaction; downstream readers in
read_committed mode see all-or-nothing. This enables the consume-transform-produce loop — the Kafka Streams / Flink-to-Kafka pattern — to be exactly-once end-to-end within the Kafka world: reprocessing after a crash cannot double-count because the offset commit and the output write share one transaction.
The boundary is the point: the guarantee holds from Kafka to Kafka. The moment an effect leaves the transactional domain — an HTTP call, an email, a database without coordinated commit — the guarantee stops at the border.
Crossing the border: transactional sinks and the outbox
- Two-phase-commit sinks: Flink coordinates external transactions with its checkpoints — pre-commit on checkpoint barriers, commit on checkpoint completion — extending effectively-once to sinks that support transactions (Kafka, JDBC with XA-ish support, Iceberg commits).
- Idempotent writes: deterministic keys + upsert semantics make replays harmless — the humble, robust answer for key-value and document sinks.
- The transactional outbox (database → stream direction): write the business row and the event into the same database transaction (outbox table), and let CDC/Debezium publish it — eliminating the dual-write inconsistency where the DB commits but the publish fails. The inverse pattern — stream → DB — stores processed-message IDs in the same transaction as the effect.
- Non-transactional side effects (emails, third-party APIs): no mechanism saves you; the pattern is at-least-once plus a dedup key honored by the receiver, i.e., idempotency pushed one hop further.
Effectively-once: the design stance
Mature systems assume at-least-once transport + idempotent effects everywhere — "effectively once." Concretely: natural or deterministic idempotency keys on every externally visible effect; upserts over inserts; versioned or conditional writes; dedup windows where keys are impractical (JetStream's approach); and treating any claimed exactly-once as a scoped guarantee whose boundary you can name. The test worth applying to every pipeline: replay yesterday against every sink — what breaks? If the answer is "nothing," you have the only exactly-once that survives contact with production. This stance also composes with event-time logic: replayable, idempotent, event-timed pipelines are the ones you can rerun after bugs — the property that makes streaming systems fixable.
See Also