Idempotency & Delivery

Exactly-Once Is a Scope, Not a Guarantee

Three different things get called exactly-once: message delivery, processing attempts, and business effect. Delivery cannot be exactly-once over an unreliable network — that is a proof. What real systems provide is exactly-once *effect* for outputs inside one transactional boundary, and at-least-once for everything outside it.

▶ Run the lab

The question this answers

The question

Systems advertise exactly-once. What are they actually offering, and where does it stop?

The guarantee — the property claimed, and its scope

Exactly-once effect for outputs written to store S from inputs read from source T, when the input position (offset/ack) and the output commit in a single atomic transaction in S. Outside S — an HTTP call, an email, a write to another database, a notification — the guarantee is at-least-once. Exactly-once *delivery* is not offered by anything, because it cannot be.

Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.

What a node knows — observation versus inference

A consumer knows the input position it has committed and the outputs it wrote in the same transaction. It knows nothing about effects it caused outside that transaction: an HTTP call it made cannot be rolled back by an aborted transaction, and the consumer cannot tell whether a previous incarnation of itself already made it. The system’s knowledge stops exactly at the transactional boundary — which is why the boundary, not the feature name, is the thing to reason about.

A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
exactly-onceEOSKafkatransactionsscope

Three things wearing one name

Almost every disagreement about exactly-once resolves once these are separated, because people arguing about it are usually discussing different ones.

Exactly-once delivery — each message arrives at the consumer exactly once. This is impossible in general. The sender cannot know whether its message arrived (A Timeout Tells You Nothing About Whether It Happened), so it must either resend on silence (at-least-once) or not (at-most-once). No exchange of acknowledgements removes this, because the acknowledgement can also be lost — the Two Generals result. Anything claiming exactly-once delivery is either wrong or is describing one of the other two.

Exactly-once processing — the handler body runs exactly once. Also not achievable: a consumer can crash mid-handler after doing half the work, and the redelivery runs the handler again. You can bound *what the reruns do*, but you cannot bound how many times the code executes.

Exactly-once effect — the observable outcome happens once, regardless of how many times the message was delivered or the handler ran. This one is achievable, and it is what every real system means. It is achieved by making duplicate work converge on a single outcome, either through idempotence or through an atomic commit that discards the duplicate.

ClaimAchievable?WhyWhat you get instead
Exactly-once deliveryprotocolNoTwo Generals — the ack can be lostAt-least-once, or at-most-once
Exactly-once processingprotocolNoA crash mid-handler forces a rerunAt-least-once execution
Exactly-once effect, in a scopeprotocolYesInput position and output commit atomicallyOne outcome for outputs inside the scope
Exactly-once effect, everywhereprotocolNoExternal systems are not in the transactionAt-least-once + idempotence
Which of the three is on offer

How exactly-once effect is actually built

The construction is always the same, whatever the product name. Commit the record of "I have consumed this input" and the output of processing it in one atomic transaction, in one store. Then a crash either commits both or neither. On redelivery, the consumer sees its committed input position, skips the already-processed message, and no second output exists.

Everything follows from where you can find a single store that holds both. In Kafka’s transactional mode the trick is that consumer offsets are themselves written to a Kafka topic, so "commit the offset" and "produce the output records" are both writes to Kafka and can be one transaction. In a database-backed worker, you store the processed message id in a table in the same transaction as the business write. In Flink, a checkpoint captures operator state and, at a transactional sink, uses a two-phase commit so state and sink output advance together.

Notice what all three have in common: the output must go somewhere that participates in the same transaction as the input position. That is the scope. It is not a setting; it is a structural property of where your data lands. An output to a system that cannot join that transaction is outside the guarantee no matter what mode is enabled.

1# INSIDE the scope: offset and output commit together.
2BEGIN TRANSACTION (in store S)
3 processed = INSERT INTO consumed(message_id) ON CONFLICT DO NOTHING
4 IF processed == 0: ROLLBACK; RETURN # already applied, exactly once
5 INSERT INTO ledger(...) # the output, in S
6COMMIT # both, or neither
7
8# OUTSIDE the scope: nothing above can undo these.
9http.post("https://payments.example/charge", ...) # cannot roll back
10smtp.send(confirmation_email) # cannot un-send
11metrics.increment("orders_processed") # not transactional
12publish_to_other_broker(event) # different store
13
14# The transaction boundary is the guarantee boundary. Everything below
15# the COMMIT line is at-least-once and needs its own idempotence.
The construction, and the line where the scope ends

What Kafka’s exactly-once actually scopes to

Kafka’s EOS is real engineering and worth understanding precisely, because it is the claim most often over-read. It combines three things. The idempotent producer gives each producer a producer id and attaches a monotonic sequence number per partition, so the broker discards a retried duplicate within that producer session — this removes duplicates caused by producer retries, per partition. Transactions let a producer atomically write to several partitions and, critically, write consumer offsets into the internal offsets topic as part of the same transaction. Read-committed consumers refuse to surface records from transactions that have not committed.

Put together, these give exactly-once for a consume-transform-produce loop where both the input and the output are Kafka topics in the same cluster. That is a genuinely useful and genuinely hard guarantee, and Kafka Streams builds on it.

What falls outside it is the part that matters for most applications. Any side effect that is not a Kafka write is not in the transaction: a database write to an external store, an HTTP call, an email, a file upload, a metric. A downstream consumer configured read_uncommitted sees aborted records. A consumer that commits offsets outside the transaction loses the atomicity. Zombie fencing depends on transactional.id being stable across restarts — reuse or regenerate it and the fencing does not apply. Producer id state expires (transactional.id.expiration.ms), and after expiry the dedup window is gone. And the whole thing is per-cluster: mirroring to another cluster is a new at-least-once boundary.

So the honest sentence is: Kafka gives exactly-once effect for Kafka-to-Kafka pipelines within one cluster, when configured correctly, and at-least-once for everything else. A team that enables EOS and then deletes its consumers’ idempotence has usually just moved the duplicates to the first external side effect, where they are harder to see.

  • Inside: input topic → output topics + offsets, one cluster, one transaction.
  • Outside: DB writes, HTTP calls, emails, metrics, files, other clusters.
  • Requires: read_committed downstream, offsets committed through the producer, a stable transactional.id.
  • Costs: added latency from transaction commit intervals, and reduced throughput from smaller effective batches.
  • Breaks quietly: an expired producer id, a read_uncommitted consumer, or one new line of code that calls an external service.

The practical model, stated plainly

At-least-once delivery plus idempotent processing. That is the design that works everywhere, composes with everything, and survives operational reality.

It is worth saying why it is preferable to a transactional mode even when one is available. It does not depend on a configuration remaining correct across every service that touches the data. It survives a manual DLQ replay, a re-drive from an admin console, a migration between brokers, and someone adding an HTTP call to a handler. It does not degrade quietly when a downstream team sets read_uncommitted for debugging and forgets. Its correctness is local to the consumer and visible in the consumer’s own code, which is the property that makes guarantees survive time and staff turnover.

Use exactly-once machinery when the pipeline genuinely is store-to-store within one system and the volume makes per-message idempotence expensive — stream aggregations are the canonical fit. Keep idempotence anyway at every boundary the transaction does not cover. And in documentation, never write "exactly-once" without the scope attached, because a downstream team will read the phrase and stop deduplicating.

How to tell whether a claim is real

When a system or a colleague claims exactly-once, three questions settle it quickly, and they work regardless of the technology.

Where does the record of "I processed this" live, and where does the output live? If the answer is not "the same store, the same transaction", the claim is about something else. What happens to a side effect that is not in that store? If the answer is "we retry it", the guarantee is at-least-once at that point. What happens when the dedup or transaction state expires or is reset? Every implementation has a bound — a retention, an expiry, a producer-id lifetime — and the guarantee ends there.

These questions also work on your own design, and asking them early is usually what reveals that the pipeline has three external effects nobody had classified.

pipeline: orders-enrichment           claimed: "exactly-once"

step                          in transaction with offset?   actual
----------------------------  ---------------------------   ------------
read from topic orders.v2     n/a                           input
write topic orders.enriched   YES (same cluster)            exactly-once
commit offset                 YES (via producer)            exactly-once
UPDATE orders_cache (Postgres) NO  <-- different store       at-least-once
POST /search/index            NO  <-- external               at-least-once
statsd.incr("enriched")       NO  <-- not transactional      at-least-once

verdict: exactly-once for the Kafka output only.
Three effects need their own idempotence. Two currently have none.
Auditing a pipeline against its claimed guarantee

Key points

  • Separate delivery, processing and effect. Only effect can be exactly-once, and only within a scope.
  • Exactly-once delivery is impossible over an unreliable network — the acknowledgement can be lost, and that is a proof, not a gap.
  • Exactly-once effect is built by committing the input position and the output atomically in one store.
  • The transactional boundary is the guarantee boundary; everything outside it is at-least-once.
  • Kafka EOS covers Kafka-to-Kafka within one cluster, with read-committed consumers and a stable transactional id.
  • External side effects — DB writes to another store, HTTP calls, emails, metrics — are never covered.
  • The composable answer is at-least-once delivery plus idempotent processing, and it should be kept even when a transactional mode is on.

The chain, answered

Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.

How it works
  • Identify the store that will hold the outputs, and confirm the input position can be recorded in the same store.
  • Begin a transaction in that store.
  • Claim the input — insert the message id, or include the offset commit in the transaction.
  • Write the outputs into the same store within the same transaction.
  • Commit. A crash before the commit leaves neither the claim nor the output, so redelivery reprocesses cleanly.
  • On redelivery of an already-committed input, the claim conflicts or the offset is already past it, and processing is skipped.
  • For every effect outside that store, apply an independent idempotence mechanism, because the transaction does not cover it.
What can fail at the boundary
  • A side effect outside the transaction is applied and the transaction then aborts, leaving an effect with no record.
  • A downstream consumer reads uncommitted records and acts on data from an aborted transaction.
  • The producer id or transactional state expires, ending the deduplication window silently.
  • A transaction times out mid-processing and the producer is fenced, aborting work that had partially escaped as external calls.
  • Offsets are committed outside the transaction by a library default, breaking the atomicity without any error.
  • The pipeline is mirrored to another cluster, crossing a boundary the guarantee does not extend across.
  • A new line of code adds an external call to a handler that was previously fully transactional.
How it fails — what an operator sees
  • Duplicate external effects after enabling EOS: the operator sees duplicate rows in an external Postgres table or duplicate emails, while the Kafka output is perfectly deduplicated. The team had removed consumer-side idempotence when the mode was turned on.
  • Downstream sees phantom records: a consumer left on read_uncommitted processes records from aborted transactions. Symptom is downstream data that has no corresponding upstream state and no error anywhere.
  • Producer fencing storms: transaction timeout is shorter than processing time, so producers are fenced mid-batch. The operator sees repeated fencing exceptions and stalled progress, and the cause is a latency change, not a configuration change.
  • Latency regression after enabling transactions: p99 end-to-end latency rises by roughly the commit interval, and throughput drops because batches are bounded by transaction boundaries. Frequently attributed to the wrong cause.
  • Silent guarantee lapse after a restart: a consumer regenerates its transactional id on each deploy, so zombie fencing never applies and a partitioned old instance can keep producing.
  • Duplicates reappear after a cross-cluster mirror is added: the guarantee held within the source cluster, and the mirroring hop reintroduced at-least-once without anyone changing the pipeline code.
Where coordination is required
  • The atomic commit of input position and output is the coordination, and it requires both to live in one store — a structural constraint, not a tunable.
  • Transactional modes add coordination on the write path: a transaction coordinator, control records, and a commit interval that sits on the latency budget (Coordination Couples Availability).
  • Fencing of zombie producers is coordination about identity and epoch, the same mechanism Fencing Tokens: Making the Stale Actor Safe, Not Just Unlikely describes for locks.
  • Every boundary crossing — another cluster, another store, an external API — is a place the coordination stops and idempotence must resume.
What still holds under failure
  • Inside the scope, a crash aborts the transaction and leaves neither the offset advance nor the output, so redelivery reproduces exactly one effect.
  • Outside the scope, effects already applied persist regardless of the transaction outcome, and duplicate on redelivery.
  • Read-committed consumers never observe records from aborted transactions, so downstream is protected from partial work — but only if configured that way.
  • When the transactional state expires or is reset, the system degrades to at-least-once without raising an error.
How it recovers
  • Detect: audit the pipeline effect by effect against the transaction boundary, and count duplicates independently at each external sink.
  • Contain: keep idempotence at every external boundary so a lapse in the transactional guarantee degrades to duplicates rather than corruption.
  • Recover: replay from the input log, which is safe inside the scope by construction and safe outside it only if the external effects are idempotent.
  • Reconcile: compare counts at each sink against input counts; the scope boundary is exactly where the ratio stops being one.
  • Verify: after any configuration change, re-run the audit — the guarantee depends on settings in several components and any one of them can silently remove it.
How you would know
  • Duplicate rate measured independently at each sink by natural key, which does not trust the transactional mode.
  • Transaction abort rate and producer fencing events, which indicate timeouts shorter than processing time.
  • Consumer isolation level in use downstream — a configuration fact worth exporting as a metric because it silently voids the guarantee.
  • End-to-end latency before and after enabling transactions, since the commit interval is added directly to it.
  • Count of external side effects per handler, tracked as a code-level inventory, because each is outside the scope.
  • Producer id / transactional id stability across restarts, since regeneration disables fencing.
When it helps
  • Stream processing where input and output are both in the same system — aggregations, joins, enrichment written back to topics.
  • High-volume pipelines where per-message dedup state would be more expensive than a transactional commit.
  • Pipelines whose outputs are consumed by other stages in the same system, so read-committed isolation protects the whole chain.
  • Anywhere the alternative would be a dedup store with a retention policy that cannot cover the replay horizon.
When it hurts
  • When it is treated as a licence to remove idempotence, moving duplicates to the first external effect where they are harder to detect.
  • When latency matters and the commit interval is on the critical path.
  • When the pipeline’s real outputs are external — a database, an API, an email — so the guarantee covers almost nothing that matters.
  • When it is quoted in a design document without a scope, causing downstream teams to build on a guarantee they do not actually have.
  • When the added configuration surface (isolation levels, transactional ids, timeouts) creates failure modes the team is not equipped to diagnose.
Simpler alternatives
  • At-least-once delivery plus idempotent processing — the composable default and the right answer in most systems (Idempotent Is a Property of the Whole Effect, Not the Write).
  • Upsert by natural key at the sink, which gives exactly-once effect with no transactional machinery at all (Deduplication: Bounded Memory Against an Unbounded Stream).
  • Store the processed message id in the same database transaction as the business write — the same construction, using the store you already have.
  • Accept duplicates and reconcile downstream, when the effect is cheap and the reconciliation is straightforward.
  • Restructure so the output lands in the same store as the input position, which converts an external effect into an internal one and makes the scope cover it.

Exactly-once is a scope, and here is where it stops

Exactly-once is a scope, and here is where it stops
One consume-transform-produce loop writing to six places. Turn the transaction on and mark which of the six it actually covers.
Inside the atomic commit?WhyDuplicate effects after the crash
output topic, same clustertypicalyescommits atomically with the input positionnone
consumer offsetstypicalyescommits atomically with the input positionnone
external Postgres tableprotocolnocannot join the transaction — different store, no two-phase participation1 extra
HTTP call to a partnerprotocolnocannot join the transaction — different store, no two-phase participation1 extra
notification emailprotocolnocannot join the transaction — different store, no two-phase participation1 extra
metrics counter (statsd)protocolnocannot join the transaction — different store, no two-phase participation1 extra
A crash mid-batch, then the redelivery. Duplicates counted per sink.
sinks covered
2/6
sinks that duplicate
4
exactly-once delivery
does not exist
delivery semantics
at-least-once
The transaction covers 2 sinks — the ones that live in the same store as the commit. The other 4 are at-least-once, and they are the ones customers can see. Enabling EOS and then removing consumer-side idempotence typically moves duplicates from a place you were measuring to a place you were not.
typicalKafka’s EOS is the concrete case modelled here. Flink, Pulsar and the transactional-outbox pattern draw the same boundary in different places — the boundary itself is not optional.

What people believe, and what is true

Claim

Exactly-once delivery solves duplicates.

Reality

Exactly-once delivery does not exist. The sender cannot learn whether its message arrived, so it must resend or not. What real systems offer is exactly-once *effect* inside a transactional scope, which is a different thing achieved by a different mechanism.

Claim

Kafka guarantees exactly-once.

Reality

Kafka’s EOS gives exactly-once *effect* for a consume-transform-produce loop where input, output and offsets are all in one Kafka cluster, with read_committed consumers and a stable transactional.id. It does not cover writes to an external database, HTTP calls, emails, metrics, files, or another cluster. It also lapses if the producer id expires or offsets are committed outside the transaction. The Kafka output is deduplicated; the side effects are not.

Claim

We enabled exactly-once, so consumers no longer need to be idempotent.

Reality

They need it for every effect outside the transaction, which usually includes the ones customers can see. Removing idempotence typically moves duplicates from a place you were measuring to a place you were not.

Claim

We store the message id in Redis before processing, so we have exactly-once.

Reality

The Redis write and the effect are two stores. A crash between them gives either a permanently blocked operation or a duplicate, depending on the order. Without one atomic commit, this is at-least-once with a dedup optimisation.

Claim

Exactly-once is just at-least-once plus deduplication, so the distinction is academic.

Reality

That is close to right, and the useful part is *where* the dedup happens. When it is inside the same commit as the output, it is exact and free of windows. When it is a separate store, it has a retention, a failure domain and a race. Naming the scope is what makes the difference visible.

Claim

A stronger delivery guarantee would remove the need to think about ordering.

Reality

Delivery semantics and ordering are independent. Exactly-once effect says nothing about the order in which effects are applied (Happens-Before: The Only Ordering You Actually Have).

Go deeper

Only the levels this lesson can honestly fill — a missing level is a claim nobody had.

Overview

Nothing delivers a message exactly once. What good systems do is make the *effect* happen once, for outputs that live in the same place as the record of what was consumed. Everything else is at-least-once and needs to be idempotent.

Practical

Audit each pipeline effect by effect and mark which are inside the transaction. Keep idempotence for every effect outside it, even with a transactional mode enabled. Verify downstream consumers use read-committed. Keep the transactional id stable across restarts. Never write "exactly-once" in a document without naming the store the guarantee applies to.

Advanced

The construction is always "commit the input position and the output atomically", and its reach is exactly the reach of that transaction. Flink generalises it with a two-phase commit sink: the checkpoint pre-commits at the sink and the completed checkpoint triggers the commit, so state and sink advance together — and inherits 2PC’s blocking window, since a sink that has pre-committed cannot decide alone. That is the same trade as The Blocking Window: When 2PC Stops and Waits and is a good reminder that exactly-once effect is bought with coordination, and priced accordingly.

Internals

Kafka assigns a producer a (producer id, epoch) pair; each record carries a sequence number per partition, and the broker rejects a sequence it has already accepted, deduplicating producer retries within the session. A transactional producer registers a transactional.id, which the transaction coordinator maps to a producer id and bumps the epoch on re-registration, fencing any zombie instance still holding the old epoch. Transaction markers (control records) are written into each involved partition, and the last stable offset (LSO) prevents read_committed consumers from advancing past an open transaction — which is also why an abandoned transaction stalls downstream consumers until it times out. Consumer offsets are written to __consumer_offsets as part of the transaction, which is precisely why the offset and the output can be atomic: they are both Kafka writes.

Apply it

Build it, then break it
  • 🔧 Audit one pipeline in your system effect by effect and produce the table of what is inside and outside the transaction boundary.
  • 🔧 Enable a transactional mode, then add an external HTTP call to the handler and demonstrate that it duplicates on redelivery while the internal output does not.
  • 🔧 Set a transaction timeout below your processing latency and observe producer fencing; then explain the operator-visible symptom.
Reason about this
  • A team enabled EOS and removed their dedup table. Two weeks later customers report duplicate emails. Explain precisely what happened.
  • A downstream service occasionally processes records that do not exist upstream. What configuration would you check first?
  • End-to-end latency rose 40ms after a release that changed no application code. What would you look at?
Interview questions
  • 💬 Someone says their system is exactly-once. What three questions do you ask?
  • 💬 Distinguish exactly-once delivery, processing and effect. Which are possible?
  • 💬 What exactly does Kafka’s exactly-once mode cover, and name three things it does not.
  • 💬 Your consumer writes to Kafka and to Postgres. Which write is covered by the transaction and what do you do about the other?
  • 💬 Why is at-least-once plus idempotence often preferable to a transactional mode that is available?