Exactly-Once Semantics in Apache Kafka, In Depth
How Kafka makes sure an event is processed and recorded once, not twice and not zero times, even when machines crash and networks fail, and where the guarantee stops.
Imagine paying a bill at a shop with a card machine. You tap your card, the screen freezes, and you do not know whether the payment went through. If you tap again and the first payment had worked, you are charged twice. If you walk away and it had failed, the shop is not paid. Every payment system has to solve exactly this puzzle: how do you retry safely without ever double charging or losing a payment? Exactly-once semantics is Kafka’s answer to that puzzle for streams of events.
This tutorial takes the topic slowly and in depth. We start with the basic ideas of delivery guarantees, then build up the two mechanisms that make exactly-once possible, the idempotent producer and transactions. We look at what happens on the consumer side, how Kafka Streams uses all of this, what the guarantee does not cover, and how to run it safely in production. Every new word is explained the first time it appears, with an everyday comparison.
1Why Delivery Guarantees Matter
Before we can talk about doing something exactly once, we need to see why doing it reliably is so hard in a system made of many machines.
A quick Kafka refresher
Apache Kafka stores streams of events in topics. An event is a small record of something that happened, such as a payment, an order or a sensor reading. Producers write events to topics, consumers read them, and brokers, the servers of the Kafka cluster, store and serve them. Each topic is divided into partitions, which are ordered logs, and every event in a partition has a position number called an offset. A consumer remembers how far it has read by saving an offset.
Machines fail in the middle of work
In a system spread over many machines, anything can fail at any moment. A network cable may drop a message. A server may crash after doing half of a job. A reply may be lost even though the work succeeded. The sender cannot tell the difference between “the request never arrived” and “the request worked but the answer was lost”. The only safe reaction, retrying, risks doing the work twice. Not retrying risks not doing it at all.
You post a cheque to a friend and never hear back. Did the post lose it, or did your friend receive it but forget to reply? If you send a second cheque and the first also arrives, your friend is paid twice. If you do nothing and the first was lost, your friend is never paid. The uncertainty, not the postal service, is the real problem.
Why this matters for business data
For some data, duplicates or gaps hardly matter. A duplicated click in a website analytics stream barely changes a chart. But for money, stock levels, bookings and billing, a duplicate or a missing event produces wrong numbers that real people notice. For these workloads, teams need a guarantee, not a hope.
Exactly-once is not about making failures disappear. Failures always happen. It is about arranging the work so that the visible result is the same as if no failure had happened.
2The Three Delivery Semantics
Every messaging system offers one of three levels of promise about how many times an event is handled.
At-most-once
An event is handled zero or one time. Never duplicated, but it may be lost. Achieved by not retrying, or by saving progress before doing the work.
At-least-once
An event is handled one or more times. Never lost, but it may be duplicated. Achieved by retrying, or by saving progress after doing the work.
Exactly-once
An event’s effect appears once. Neither lost nor duplicated, even across failures and retries.
flowchart LR
subgraph AMO["At-most-once"]
A1["Save progress"] --> A2["Do the work"]
A2 -. "crash here: work lost" .-> A3["Event skipped"]
end
subgraph ALO["At-least-once"]
B1["Do the work"] --> B2["Save progress"]
B1 -. "crash before saving: work repeated" .-> B3["Event handled twice"]
end
subgraph EO["Exactly-once"]
C1["Work and progress"] --> C2["Committed together or not at all"]
end
A teacher marking homework can tick the register first and then mark the book, or mark the book first and then tick the register. If the bell rings in between, the first method leaves a ticked register with an unmarked book, and the second leaves a marked book with an unticked register that may cause a second marking. Exactly-once is like doing both in one motion so the bell can never split them.
What Kafka gives by default
A plain Kafka producer with safe settings and a consumer that saves its offset after processing gives at-least-once behaviour. Nothing is lost, but retries and restarts can create duplicates. The features covered in this tutorial extend this default up to exactly-once for a defined scope.
| Guarantee | Can Events Be Lost? | Can Events Be Duplicated? | Typical Use |
|---|---|---|---|
| At-most-once | Yes | No | Metrics and logs where gaps are tolerable |
| At-least-once | No | Yes | Most pipelines, especially with duplicate-safe consumers |
| Exactly-once | No | No, within the guaranteed scope | Payments, billing, inventory, counters |
3Where Duplicates and Losses Come From
To fix a problem we must find exactly where it appears in the journey of an event.
Failure point one: producer retries
A producer sends a batch of events to a broker and waits for an acknowledgement. If the acknowledgement is lost, or a timeout occurs, the producer does not know whether the broker stored the batch. It retries, and if the first attempt had actually succeeded, the broker now holds the batch twice.
sequenceDiagram
participant P as Producer
participant B as Broker
P->>B: Send batch of events
B->>B: Store batch in the log
B--xP: Acknowledgement lost on the network
Note over P: No reply, so producer retries
P->>B: Send the same batch again
B->>B: Store batch a second time
Note over B: Duplicate events in the log
Failure point two: the consumer’s read-process-write cycle
Many applications read an event, process it and write a result to another topic, then save the input offset. If the application crashes after writing the result but before saving the offset, it restarts from the old offset, reads the same event again and writes the result a second time.
Failure point three: zombie instances
A zombie is an old copy of an application that everyone thought was dead, but which is actually just slow or disconnected. Meanwhile a replacement copy has taken over. If the zombie wakes up and keeps writing, both copies produce output for the same input, causing duplicates or corrupted results.
Failure point four: broker failures
If a broker holding a partition leader fails, another replica takes over. Events acknowledged only by the failed broker but not yet copied may be lost, unless the producer required acknowledgement from all in-sync replicas. Safe settings are the foundation for any stronger guarantee.
Producer retry
Fixed by the idempotent producer, which lets the broker recognise repeats.
Crash between output and offset
Fixed by transactions that commit output and offsets together.
Zombie instance
Fixed by producer epochs that fence out old copies.
Broker failure
Fixed by replication and safe acknowledgement settings.
Think of a warehouse worker who packs a parcel, then ticks it off a list. If the lights go out between packing and ticking, the next worker sees an unticked item and packs it again. Or a retired worker who never got the message keeps packing parcels beside the new worker. Each of these has its own fix.
4What Exactly-Once Really Means in Kafka
The phrase sounds absolute, so it is worth being precise about what is and is not promised.
The honest definition
Exactly-once semantics in Kafka means that when an application reads events from Kafka topics, processes them and writes results back to Kafka topics, the results appear in the output exactly once, as if there had been no failures. The guarantee covers the whole read-process-write cycle, including the saving of input progress. It is achieved by combining idempotent producers, transactions and consumers that read only committed data.
Effect, not execution
Exactly-once does not mean your code runs exactly once. After a crash, your code may well run again on the same input. What it means is that the effects inside Kafka, the output events and the saved offsets, are visible exactly once. Aborted attempts leave no visible trace for properly configured readers.
The boundary of the guarantee
The guarantee covers work that starts and ends inside Kafka. If your application also sends an email, calls a payment service or updates a database while processing an event, those external actions are outside Kafka’s transaction. Kafka cannot undo an email that has already been sent. This boundary is the most important limit to remember, and later chapters show how to handle it.
flowchart LR
subgraph IN["Inside Kafka: guaranteed"]
T1["Input topic"] --> APP["Processing application"]
APP --> T2["Output topic"]
APP --> OFF["Saved offsets"]
end
APP -. "not covered by Kafka transactions" .-> EXT["Email, database, HTTP call"]
A bank can guarantee that moving money between two accounts inside the bank happens once. It cannot guarantee that a paper letter you also asked it to post is not printed twice. The guarantee covers what the bank itself controls.
Believing that turning on exactly-once makes an entire architecture free of duplicates. It protects the Kafka part. Any external database, payment call or email in the middle still needs its own protection.
5Building Block One: The Idempotent Producer
The first mechanism removes duplicates caused by producer retries.
What idempotent means
An operation is idempotent when doing it many times has the same effect as doing it once. Pressing the lift call button five times does not summon five lifts. Setting a light switch to “on” repeatedly leaves the light on. An idempotent producer can safely resend the same batch, because the broker will store it only once.
How the broker recognises repeats
When idempotence is on, the producer receives a unique number from the broker called a producer ID, and every batch it sends to a partition carries a sequence number that increases by one each time. The broker remembers the latest sequence numbers per producer and partition. If a batch arrives with a sequence number it has already stored, the broker recognises the repeat, does not write it again, and simply acknowledges it.
sequenceDiagram
participant P as Producer, ID 42
participant B as Broker
P->>B: Batch, sequence 7
B->>B: Store batch, remember last sequence 7
B--xP: Acknowledgement lost
P->>B: Retry batch, sequence 7
B->>B: Sequence 7 already stored, ignore
B-->>P: Acknowledge without storing again
A cinema numbers each ticket a customer buys. If the customer repeats the request because they did not hear the answer, the cashier checks the numbered list, sees ticket seven is already issued, and just repeats the confirmation instead of printing another ticket.
Settings that go with it
In recent Kafka versions, idempotence is turned on by default for producers. It requires that the producer waits for acknowledgement from all in-sync replicas, that retries are enabled, and that the number of unacknowledged requests per connection stays within a small limit, so ordering is preserved even when retries happen. If you configure conflicting values, the producer refuses to start instead of silently weakening the guarantee.
Advantages of Idempotence
- Removes duplicates caused by retries
- Preserves ordering within a partition during retries
- Very low overhead
- No changes needed in application logic
Limits
- Protects only a single producer session and a single partition at a time
- Does not link output to the input offset
- Does not cover writes across several partitions atomically
Even if you never use transactions, idempotent producers are a free improvement for almost every application, because they remove a whole class of retry duplicates at almost no cost.
6Inside Idempotence: Producer IDs, Epochs and the Dedup Window
A closer look at the machinery shows both why it works and where its edges are.
Producer ID and epoch
Each producer session is identified by a producer ID and an epoch. The epoch is a counter attached to the ID that can be increased to signal a newer session. The pair is what the broker uses to decide whether a batch is a repeat or new work.
Sequence numbers per partition
Sequence numbers are tracked separately for each partition that a producer writes to. The broker expects the next batch to have exactly the next number. If a number is skipped, meaning something was lost, the broker raises an out-of-order error. If a number is repeated, it is treated as a duplicate and ignored.
The limited memory of the broker
The broker does not remember every batch forever. It keeps information only about a limited number of the most recent batches for each producer and partition. This is enough to catch ordinary retries, because retries happen quickly, but it also explains the requirement to limit the number of unacknowledged requests in flight.
What happens on restart
A plain idempotent producer that is restarted receives a new producer ID, because the broker has no way to know the new process is the continuation of the old one. Its sequence numbers start again from zero, so it cannot be recognised as a replacement. This is why idempotence alone protects only within one running session, and why transactions introduce a stable identity that survives restarts.
| Concept | What It Is | Why It Matters |
|---|---|---|
| Producer ID | Number assigned by the broker to a producer session | Identifies whose batches these are |
| Epoch | Counter attached to the producer ID | Lets a newer session replace an older one |
| Sequence number | Increasing number per producer and partition | Lets the broker spot repeats and gaps |
| Dedup memory | Recent batches remembered by the broker | Bounds how far back duplicates can be caught |
A cashier remembers the last few customers’ ticket numbers, not all of history. That is plenty to catch someone repeating a request seconds later. But if the customer leaves and returns tomorrow as a new person with a new number series, the cashier has no way to connect the two visits.
7Why Idempotence Alone Is Not Enough
The idempotent producer solves one problem well, but real pipelines have bigger ones.
Gap 1: Restarts create a new identity
After a crash and restart, the producer gets a new ID. If it resends events it was unsure about, the broker cannot recognise them as repeats, so duplicates reappear.
Gap 2: Several partitions are written independently
If one input event produces output to two different partitions, an idempotent producer cannot make the pair succeed or fail together. A crash can leave one written and the other missing.
Gap 3: Output and offset are separate
The saving of the consumer’s input offset is a separate step from writing output. A crash between them yields duplicate outputs after restart.
Gap 4: No protection from zombies across sessions
Two live producers with different IDs writing the same logical output cannot be distinguished, so an old copy and its replacement may both write.
Each gap is a place where two things must happen together but Kafka, without more help, treats them as separate. Transactions are the tool that joins them.
A numbered ticket system stops the cashier double-issuing during one shift. It does not help when a customer must buy a ticket and a coupon together, or when a colleague from yesterday’s shift is still handing out tickets. For those you need a bigger system that treats several actions as one.
8Building Block Two: Transactions
A transaction makes several writes and the saving of offsets succeed or fail as a single unit.
What a Kafka transaction is
A Kafka transaction lets a producer write events to several partitions, possibly in several topics, and include the saving of consumer offsets, all as one atomic operation. Atomic means all or nothing. Either every write in the transaction becomes visible to readers, or none of them does.
The transactional identifier
To use transactions, the application sets a transactional identifier, a name that you choose and keep the same across restarts. Kafka maps this name to a producer ID. When a new instance starts with the same name, Kafka recognises it as the continuation of the old one, hands it the same producer ID with a higher epoch, and finishes or cancels any half-done transaction left by the previous instance. This is what fixes the identity problem of the idempotent producer.
Think of a bank transfer between two accounts. The bank subtracts from one and adds to the other as one action. If anything breaks halfway, the bank undoes the half-done work so no money vanishes or appears. The transactional identifier is the bank’s record of which teller was handling that transfer, so a replacement teller can pick up or cancel it.
Atomic writes
Writes to many partitions and topics become visible together or not at all.
Atomic offsets
The consumer’s input progress is committed inside the same transaction as the output.
Stable identity
The transactional identifier survives restarts so replacements can take over cleanly.
Fencing
Older copies with lower epochs are refused, so zombies cannot write.
The transactional identifier must be stable for the same logical piece of work, and unique between different pieces of work that run at the same time. Two live instances sharing one identifier will keep fencing each other out.
9The Transaction Coordinator and the Transaction Log
Someone has to keep the books for every transaction. In Kafka, that job belongs to a broker role and an internal topic.
The transaction coordinator
Every transactional identifier is assigned to one broker acting as its transaction coordinator. The coordinator hands out producer IDs and epochs, records which partitions a transaction has written to, and drives the commit or abort process. The assignment is decided by hashing the transactional identifier onto a partition of the internal transaction log, and the leader of that partition acts as the coordinator.
The transaction log
The coordinator stores its state in a special internal topic. Every change in a transaction’s state, such as ongoing, preparing to commit, or completed, is written there before it takes effect. Because it is a replicated Kafka topic, the state survives broker failures, and a new coordinator can read it to continue where the old one stopped.
flowchart TB
APP["Producer with a transactional ID"] --> CO["Transaction coordinator broker"]
CO --> TL[("Internal transaction log topic")]
APP --> P1["Data partition A"]
APP --> P2["Data partition B"]
CO --> P1
CO --> P2
A large event has a registration desk with a ledger. Every group that arrives is written into the ledger with what they are up to, and the desk decides when the group is officially in or out. If the desk clerk leaves, a replacement reads the ledger and carries on with no confusion.
Safety settings of the log
Because the transaction log is the source of truth, its replication and minimum in-sync replica settings matter greatly. Kafka defaults these to safe values for production clusters. Small development clusters with a single broker need these lowered, but they should never be lowered in production.
A cluster with fewer brokers than the transaction log’s replication setting requires cannot create the log, and transactions will not start. Plan enough brokers before enabling transactions.
10The Life of a Transaction, Step by Step
Following one transaction from start to finish shows how every piece fits.
Initialise the producer
On startup, the producer contacts the coordinator with its transactional identifier. The coordinator returns a producer ID and a new epoch, and completes or aborts anything the previous instance left unfinished.
Begin the transaction
The application marks the start of a transaction. This happens locally, and nothing is sent yet.
Register partitions before writing
The first time the producer writes to a new partition within the transaction, it tells the coordinator, which records the partition in the transaction log.
Write the output events
Events are sent to the partitions as usual. They are stored in the logs immediately but are marked as part of an open transaction.
Include the consumer offsets
The producer sends the input offsets to the coordinator as part of the same transaction. They will only take effect if the transaction commits.
Commit or abort
The application asks the coordinator to commit. The coordinator runs the finishing process described in the next chapter. If anything went wrong, the application aborts instead.
Repeat with the next batch
The application begins a new transaction for the next group of input events. Each transaction handles a batch, not a single event, to keep overhead low.
flowchart TD
R["Read batch of input events"] --> B["Begin transaction"]
B --> W["Process and write output events"]
W --> O["Add input offsets to the transaction"]
O --> C{"All succeeded?"}
C -- "Yes" --> CM["Commit: output and offsets visible together"]
C -- "No" --> AB["Abort: nothing becomes visible"]
CM --> R
AB --> R
Each transaction has a fixed cost. Wrapping a batch of many events in one transaction spreads that cost thinly, while wrapping every single event in its own transaction makes throughput poor.
11Two-Phase Commit and Control Markers
The heart of atomicity is a careful two-step ending that survives crashes at any moment.
Phase one: decide
When the producer asks to commit, the coordinator first writes a “preparing to commit” record in the transaction log. Once this is safely stored, the decision is final. Even if the coordinator crashes now, its replacement will read the log, see the decision, and carry it out. The transaction can no longer be aborted at this point.
Phase two: tell everyone
The coordinator then sends a commit marker to every partition that took part in the transaction. A commit marker is a special control record appended to the partition’s log, not visible to normal applications. It states that all events from this producer’s transaction are now committed. After all partitions have the marker, the coordinator writes a “completed” record in its own log, and the transaction is finished.
sequenceDiagram
participant P as Producer
participant C as Coordinator
participant L as Transaction log
participant D as Data partitions
P->>C: Commit transaction
C->>L: Write "preparing to commit"
Note over C,L: Decision is now final
C->>D: Write commit markers
D-->>C: Markers written
C->>L: Write "committed, complete"
C-->>P: Commit successful
Abort works the same way
An aborted transaction follows the same two steps, but the markers say “abort”. Readers that respect transactions then skip the events belonging to the aborted transaction as if they were never written. The events remain in the log until normal retention removes them, but they are ignored.
A wedding planner first records in a notebook, “the wedding is going ahead”. After that entry exists, nothing can cancel it. Then the planner phones every supplier to confirm. If the planner falls ill halfway through the calls, a colleague reads the notebook and finishes the calls, so every supplier eventually gets the same answer.
Control markers take up offsets in the partition, so you may notice small gaps in offset numbers when transactions are used. This is normal and does not mean data is missing.
12Zombie Fencing with Epochs
A revived old instance must never be able to spoil the work of its replacement. Epochs make that possible.
How fencing works
Every time an application starts up and registers its transactional identifier, the coordinator increases the epoch for that identifier. From then on, the coordinator and the partition leaders only accept writes carrying the newest epoch. If an old instance wakes up and tries to write with its lower epoch, the request is rejected with an error stating that the producer has been fenced. The zombie learns it has been replaced and must stop.
sequenceDiagram
participant Z as Old instance, epoch 3
participant N as New instance
participant C as Coordinator
participant D as Partition
Note over Z: Old instance stalls
N->>C: Register same transactional ID
C-->>N: Epoch 4 granted, epoch 3 fenced
N->>D: Write with epoch 4
D-->>N: Accepted
Z->>D: Wakes up, writes with epoch 3
D-->>Z: Rejected: producer fenced
A night guard receives a new badge every shift with a higher number. The gate only opens for the highest badge number issued. If yesterday’s guard returns and swipes an old badge, the gate stays shut and a light tells them they have been replaced.
Why a stable transactional identifier matters
Fencing works only if the replacement uses the same transactional identifier as the instance it replaces. If identifiers were random per start, the coordinator would treat each as a new, unrelated producer and could not fence anyone. This is why identifiers should be derived from the logical role, for example the input partition being processed, rather than generated freshly at each launch.
Reacting to being fenced
A fenced producer is not a temporary error to retry. It means a newer instance owns the work. The correct reaction is to stop using that producer and let the application shut down or rebuild its state, depending on design.
Catching the fencing error and simply retrying in a loop. The fenced instance can never succeed, and continuing may cause confusing behaviour. Treat it as a signal to stop.
13The Consumer Side: Isolation Levels and the Stable Offset
Atomic writes are useless if readers can still peek at half-finished transactions. Consumers must be told to respect them.
Two isolation levels
A consumer can be set to one of two isolation levels. In the read-uncommitted level, which is the default, it sees every event as soon as it is written, including events from transactions that are still open or later aborted. In the read-committed level, it sees only events that belong to committed transactions, plus events that were not written in a transaction at all.
The last stable offset
To support read-committed, each partition keeps track of a position called the last stable offset. It is the offset up to which every transaction has been decided, either committed or aborted. A read-committed consumer never reads beyond this point. Events belonging to a still-open transaction sit beyond the last stable offset until the transaction finishes.
flowchart LR
A["Committed events"] --> B["Decided transaction"] --> LSO["Last stable offset"] --> C["Open transaction events"] --> HW["Newest written offset"]
| Isolation Level | Sees Open Transactions | Sees Aborted Events | Latency |
|---|---|---|---|
| Read-uncommitted | Yes | Yes | Lowest |
| Read-committed | No, waits for the decision | No, they are filtered out | Higher, bounded by transaction length |
A newspaper editor sends finished stories to print only after the whole edition is approved. A reporter with early access to the draft pages may see stories that are later pulled. The last stable offset is the line on the page below which everything is still being decided.
A stuck transaction blocks readers
Because read-committed consumers cannot pass the last stable offset, one transaction that stays open for a long time holds up all readers of that partition. The transaction timeout exists to prevent this: if a producer disappears without finishing, the coordinator eventually aborts its transaction, letting the last stable offset move on. Choose the timeout with this in mind.
Exactly-once needs cooperation from both sides. If downstream consumers stay at the default read-uncommitted level, they will happily read data from aborted transactions, and the guarantee is lost for them.
14The Read-Process-Write Pattern and Atomic Offsets
Now we can put the pieces together into the classic exactly-once loop.
The problem it solves
Recall the crash between writing output and saving the input offset. In the classic loop, the application reads a batch, transforms it and writes output, and then saves the offset. To repair the gap, the offset must be saved not by the consumer on its own, but through the producer’s transaction, so it commits or aborts together with the output.
The steps in words
Disable automatic offset saving
The consumer must not commit offsets itself. Offset control moves to the transaction.
Read a batch at read-committed
The consumer reads only committed input, so it never builds output from data that may be aborted upstream.
Write outputs inside a transaction
The application writes results through a transactional producer.
Send offsets through the transaction
The producer hands the consumer’s positions, along with the consumer group’s identity, to the coordinator as part of the transaction.
Commit
Output and offsets become visible together. After a crash, the restarted application resumes from the last committed offsets and any uncommitted output is ignored.
A cook reads an order, prepares the dish and ticks the order off. In the exactly-once kitchen, sending the dish out and ticking the order off are done in one indivisible motion. If the cook faints halfway, the dish is pulled back and the order remains unticked, so the next cook starts it fresh with no double servings.
The saved offset is really just another write into a Kafka topic, an internal one that stores consumer progress. That is why it can be part of the same transaction as the output events.
Rebalances and the consumer group
When a consumer group rebalances, partitions move between instances. Including the consumer group’s metadata in the offset submission lets the broker check that the instance committing offsets still owns those partitions, and refuse commits from a stale owner. This closes another zombie loophole on the consumer side.
15Exactly-Once in Kafka Streams
Most teams do not write the transaction loop by hand. Kafka Streams does it for them behind a single setting.
One switch
Kafka Streams is a library for building stream processing applications. To enable exactly-once, you set its processing guarantee to the exactly-once option. The current recommended option is often called exactly-once version two. Older options are deprecated. With this on, the library configures idempotent, transactional producers and read-committed consumers, and wraps its processing in transactions automatically.
What goes into each transaction
A stream application does more than read and write topics. It also updates local state stores, and it sometimes writes to internal repartition topics. Under exactly-once, the transaction contains all of these together: the output events, the writes to the state store’s backup topic, the repartition topic writes and the input offsets. They all commit or all abort.
flowchart TB
IN["Input topic"] --> T["Stream task"]
T --> ST[("Local state store")]
subgraph TX["One transaction"]
O["Output topic writes"]
CL["State store backup topic writes"]
OF["Input offsets"]
end
T --> O
T --> CL
T --> OF
Version two and efficiency
Earlier exactly-once versions used one transactional producer per task, which meant many producers and heavy overhead for applications with many partitions. Version two uses one producer per stream thread, which greatly reduces resource use while keeping the same guarantee. It requires reasonably recent brokers.
Commit interval changes
Under exactly-once, transactions are committed frequently, with a much shorter default interval than under at-least-once. Shorter intervals lower the delay before downstream readers see results, at the cost of more transactions. You can tune this to trade latency against overhead.
State stores and unclean shutdowns
Local state may hold changes from a transaction that never committed. After an unclean crash, Kafka Streams may discard the local files and rebuild them from the committed backup topic, so that state matches the committed truth. This costs restoration time but protects correctness.
Advantages in Streams
- A single setting covers the entire pipeline
- State and output are consistent after failures
- Correct counts and aggregates without extra code
- Version two scales well with many partitions
Trade-offs
- Higher end-to-end latency for downstream readers
- Additional load on brokers for markers and transaction state
- Longer recovery after unclean shutdowns
Instead of asking each kitchen worker to coordinate their own paperwork, the restaurant installs a single order system that handles the dish, the stock count and the ticket together. Workers just cook, and the system keeps the books straight.
16Beyond Kafka: External Systems and Connectors
Real pipelines start and end outside Kafka, and that is where the boundary of the guarantee matters most.
Getting data in: source connectors
Kafka Connect moves data between Kafka and other systems. Source connectors read from a database or a service and write to Kafka. Recent versions support exactly-once for source connectors that are able to participate: the connector’s progress and its written records commit together in a transaction. Not every connector supports it, so check the documentation of each one.
Getting data out: sink connectors
For sink connectors, which write from Kafka into another system, the outside system usually decides the guarantee. If the target can accept a write with a unique key and ignore repeats, or can store the Kafka offset atomically together with the data, then repeated delivery causes no harm. Otherwise, duplicates may appear on the far side.
Outside systems cannot join Kafka transactions
A Kafka transaction cannot include a database update, an email or a call to another service. If your application updates a database and then crashes before committing the Kafka transaction, the database keeps the change while Kafka rolls back. On restart, the update may run again. For those cases you need extra patterns, described in the next chapter.
flowchart LR
SRC["Source system"] -->|"Source connector: can be exactly-once"| K["Kafka topics"]
K -->|"Kafka Streams: exactly-once inside Kafka"| K2["Kafka output topics"]
K2 -->|"Sink connector: depends on the target system"| DST["Database or service"]
A warehouse can guarantee that stock moves between its shelves once. But when a truck driver from another company takes the parcel away, what the driver’s company does with it is out of the warehouse’s control, unless that company also follows careful rules.
17Effectively-Once: Patterns for External Side Effects
When you cannot make an outside action part of the transaction, you make it safe to repeat. The result behaves as if it happened once.
The idempotent consumer
Give every event a unique identifier. Before acting, check whether that identifier has already been handled, using a table or store. If it has, skip the action. Repeated delivery then does no harm, whatever the delivery guarantee.
Idempotent writes with natural keys
Design outside writes as “set this record to this value” instead of “add this to the total”. Setting a value repeatedly has the same effect as setting it once, while adding repeatedly changes the result.
Store the offset with the data
When writing to a database, save the Kafka offset in the same database transaction as the data. On restart, read the stored offset and continue from there, so the data and its position move together in the database’s own transaction.
The transactional outbox
When a service must both update its database and publish an event, write the event into an outbox table in the same database transaction. A separate process then reads the outbox and publishes to Kafka, so the two are never out of step, and publishing can be repeated safely.
Idempotency keys for outside services
Many payment and messaging services accept an idempotency key with each request and refuse to repeat the action for the same key. Derive the key from the event identifier so retries collapse into one action.
When posting a parcel, you write a unique tracking number on the label. If the courier receives the same number twice, they know it is the same parcel and do not deliver two. Effectively-once is giving every action a tracking number.
Many mature systems combine both worlds: exactly-once inside Kafka for correct streams and aggregates, and idempotent handling at every external edge. Belt and braces is normal here.
18Configuration and Cluster Requirements
Exactly-once depends on safe underlying settings. Weak settings quietly undermine it.
| Area | Requirement | Why |
|---|---|---|
| Producer acknowledgements | Wait for all in-sync replicas | An event acknowledged by only the leader can vanish if that broker fails |
| Producer retries | Enabled, with idempotence on | Retries are safe only because the broker removes repeats |
| Transactional identifier | Stable per logical worker, unique across workers | Enables takeover and fencing |
| Consumer isolation | Read-committed for every consumer of transactional output | Prevents reading aborted or open data |
| Consumer offset saving | Managed through the transaction, not automatic | Keeps offsets and output atomic |
| Topic replication | Replication factor of at least three with a minimum of two in-sync replicas | Survives a broker loss without losing committed data |
| Transaction log | Replicated and with a safe minimum in-sync setting | It is the source of truth for transactions |
| Transaction timeout | Longer than typical processing, shorter than tolerable delay | Balances false aborts against blocked readers |
| Unclean leader election | Disabled | Prevents promoting an out-of-date replica that lacks committed data |
A vault with a perfect combination lock is useless if the walls are made of cardboard. Exactly-once is the lock. Replication and acknowledgement settings are the walls, and both must be strong.
Version awareness
Exactly-once features have evolved across Kafka releases, and the newest Streams option needs reasonably recent brokers. Before adopting it, confirm the versions of your brokers, clients and libraries, and read the release notes for your version.
Turning on transactions while leaving replication and in-sync settings weak. Committed data can still be lost in a broker failure, and the promise of exactly-once quietly breaks.
19Performance and Cost
Stronger guarantees are not free. Knowing the costs helps you decide where they are worth paying.
Where the extra cost comes from
Coordinator traffic
Extra requests to register partitions, add offsets and commit or abort each transaction.
Commit markers
A marker written to every partition touched by a transaction, so touching many partitions costs more.
Read-committed delay
Consumers wait for the commit decision before reading, so results arrive later.
Recovery work
Aborted transactions and state rebuilds after crashes take time.
What influences the overhead
The fixed cost per transaction is spread across the events inside it. Larger batches per transaction dilute the overhead and raise throughput, while a very short commit interval adds cost and reduces efficiency. The number of partitions written per transaction also matters, because each needs a marker. Modest transactions over a limited set of partitions are cheaper than sprawling ones.
The latency trade-off
The commit interval directly sets the delay before downstream read-committed consumers can see new results. A short interval reduces the delay but increases overhead. A longer interval improves throughput but means results arrive in larger, later chunks. Choose based on how fresh the downstream data must be.
A courier can take one parcel per trip for maximum speed on each parcel, or wait to fill the van and make fewer trips. Small trips mean faster individual deliveries but higher total cost. Big trips are cheaper per parcel but each parcel waits longer.
The real overhead depends on message sizes, batch sizes, partition counts and hardware. Test with your own workload, and compare throughput and latency with and without exactly-once before deciding.
20Failure Scenarios Walked Through
The best way to trust the design is to watch it handle each failure in turn.
Scenario A: The acknowledgement is lost
The producer retries the batch. The broker sees the same producer ID and sequence number, drops the repeat and acknowledges. No duplicate appears.
Scenario B: The application crashes mid-transaction
The transaction never commits. When the replacement starts with the same transactional identifier, the coordinator aborts the unfinished transaction. Its events remain in the logs but are skipped by read-committed consumers. The replacement resumes from the last committed offsets and reprocesses the batch cleanly.
Scenario C: The application crashes after the commit decision
The decision was already recorded in the transaction log. The coordinator finishes writing commit markers to all partitions. The output becomes visible along with the offsets, so the replacement continues from the next batch with no duplicates.
Scenario D: The coordinator broker fails
Another broker takes over leadership of the transaction log partition, reads the recorded state and carries on. In-flight transactions either complete or abort according to what the log says.
Scenario E: A zombie instance wakes up
Its old epoch is rejected. The zombie gets a fenced error and must stop. The newer instance’s work is untouched.
Scenario F: A transaction takes too long
The coordinator aborts it after the timeout. The producer’s next attempt to commit fails, and the application must restart the batch. Meanwhile, read-committed consumers are unblocked once the abort marker is written.
Scenario G: A leader replica fails before copying data
Because the producer required acknowledgement from all in-sync replicas, data reported as written exists on more than one broker. The new leader has it. Nothing acknowledged is lost.
In every scenario, the safe outcome depends on a durable record written before the risky step. Decisions are logged first, and actions follow the log.
21Security and Governance
Transactions add new resources and new powers, and they need the same care as any sensitive feature.
Permissions on transactional identifiers
When access control is enabled, a transactional producer needs permission to use its transactional identifier as a resource, in addition to permission to write to the topics. Grant each application access only to its own identifiers.
Protect the internal topics
The transaction log and the consumer offsets topic hold the truth about progress. Only brokers should write to them, and reading them should be limited to administrators and tools that need it.
Prevent identifier hijacking
Anyone who can use another application’s transactional identifier can fence it out and take over its work. Give identifiers meaningful names, restrict who can use them, and audit their usage.
Encrypt and authenticate connections
Transaction requests carry sensitive control information. Use encrypted, authenticated connections between clients and brokers, as for any other traffic.
Limit blast radius of a stuck producer
Because a long-running open transaction blocks read-committed readers, set sensible timeout limits at the broker and per application, so no single client can stall a shared topic for long.
A staff badge number that lets its holder take over a colleague’s till is powerful. It must be issued carefully, matched to a person and logged, or someone could quietly take over another cashier’s counter.
22Monitoring Exactly-Once Pipelines
A guarantee you cannot observe is a guarantee you cannot trust. These signals show whether things are healthy.
| Signal | What It Tells You | Typical Response |
|---|---|---|
| Fenced producer errors | Two instances share an identifier, or a zombie is active | Check identifier assignment and deployment overlap |
| Transaction abort rate | How often batches fail and are redone | Look for timeouts, crashes or bad input |
| Commit latency | Time to finish a transaction | Check coordinator load and partition spread |
| Last stable offset lag | A transaction holding readers back | Find long-running or hung transactions |
| Consumer lag at read-committed | How far behind readers are, including transaction delay | Tune commit interval and consumer capacity |
| Out-of-order sequence errors | Gaps in producer sequences | Investigate data loss or unclean broker events |
| Transaction log health | Health of the coordinator’s storage | Check replication and broker capacity |
| Streams restore time | Time to rebuild state after unclean stops | Use standby replicas and durable local storage |
End-to-end checks
Add business-level reconciliation on top of technical metrics. For example, compare the total of input amounts with the total of output amounts over a period. Such checks catch problems that no low-level metric reveals, including duplicates introduced at the outside edges of the pipeline.
A shop that has a perfect till system still counts the cash at the end of the day. The daily count is an independent check that catches mistakes no matter how good the system is.
23Design Patterns and Anti-Patterns
Practices that make exactly-once work smoothly, and the traps that quietly defeat it.
Patterns worth adopting
Idempotent producers everywhere
Enable idempotence for all producers as a baseline. It costs almost nothing and removes retry duplicates.
Transactions for read-process-write
Use transactions when output must be tied to input progress, for example in pipelines that compute balances or counters.
Idempotent edges
Even with exactly-once inside Kafka, make external actions safe to repeat, using event identifiers and idempotency keys.
Right-sized transactions
Batch enough events per transaction to keep overhead low, but keep transactions short enough to avoid blocking readers.
Anti-patterns to avoid
Problem
Enabling transactions on the producer but leaving downstream consumers at the default read-uncommitted level.
Why It’s Harmful
Downstream readers see events from aborted transactions and open transactions, so duplicates and phantom data reach them despite the effort upstream.
Correct Approach
Set every consumer of transactional topics to read-committed, and make this a documented standard.
Problem
Generating a random transactional identifier at every startup.
Why It’s Harmful
The coordinator cannot link a restarted instance to its predecessor, so unfinished transactions and zombies are not handled, and the safety of exactly-once is lost.
Correct Approach
Derive the identifier from the stable role of the worker, such as the application name and the input partition it owns.
Problem
Doing external side effects, such as sending emails or charging cards, inside the transaction loop and assuming Kafka will undo them on abort.
Why It’s Harmful
Kafka cannot roll back outside actions. After an abort and retry, the outside action runs again, producing duplicate emails or charges.
Correct Approach
Make outside actions idempotent, use an outbox, or publish an event and let a separate idempotent consumer perform the action.
Problem
One transaction per single event in a high-volume pipeline.
Why It’s Harmful
The fixed cost of each transaction dominates, reducing throughput drastically and loading the coordinator.
Correct Approach
Process events in batches per transaction, tuning the batch size or commit interval to the latency you need.
Problem
Retrying forever after a fenced producer error, or ignoring it.
Why It’s Harmful
The instance is no longer the owner of the work. Continuing risks confusion, and looping hides a deployment problem such as two live copies sharing an identifier.
Correct Approach
Stop the fenced instance, investigate why overlap occurred and fix the assignment or deployment process.
24Choosing the Right Guarantee
Exactly-once is powerful but not always necessary. A simple decision path helps you choose.
flowchart TD
S["What happens if an event is duplicated or lost?"] --> Q1{"Is loss acceptable?"}
Q1 -- "Yes, gaps are fine" --> AMO["At-most-once"]
Q1 -- "No" --> Q2{"Can consumers safely handle duplicates?"}
Q2 -- "Yes, idempotent logic" --> ALO["At-least-once with idempotent producer"]
Q2 -- "No, results would be wrong" --> Q3{"Does the whole cycle stay inside Kafka?"}
Q3 -- "Yes" --> EOS["Exactly-once with transactions or Streams"]
Q3 -- "No, outside systems involved" --> MIX["Exactly-once inside plus idempotent outside actions"]
Ask what a duplicate or a loss would cost
A repeated page view is trivial. A repeated payment or a lost booking is serious. The cost decides how much protection to buy.
Turn on the safe baseline
Use idempotent producers, replication with in-sync requirements and all-replica acknowledgements for every important topic.
Decide whether the logic is naturally repeat-safe
Setting a value, keeping a set of seen identifiers or replacing a record by key can tolerate repeats. Counting and adding cannot.
Adopt transactions or Streams exactly-once where needed
Use them for read-process-write pipelines where repeats would corrupt results, and set downstream consumers to read-committed.
Secure the edges
Add idempotency keys, outbox patterns or idempotent sink writes wherever data leaves Kafka.
Test failures on purpose
Kill instances mid-transaction, delay networks and restart brokers in a test environment, and confirm results stay correct.
If you can make your logic naturally repeat-safe, at-least-once with an idempotent design is often simpler and cheaper than transactions. Use exactly-once where repeat-safety is hard to achieve.
25Best Practices and Common Mistakes
A quick reference for teams adopting or reviewing exactly-once designs.
Best Practices
- Enable idempotence for all producers
- Use stable, role-based transactional identifiers
- Set consumers of transactional data to read-committed
- Keep transactions short and batched sensibly
- Use safe replication and in-sync settings
- Prefer Kafka Streams exactly-once over hand-written loops when it fits
- Make outside actions idempotent
- Monitor aborts, fencing and last stable offset lag
- Test failure scenarios before production
Common Mistakes
- Believing exactly-once covers outside systems
- Leaving consumers at read-uncommitted
- Random or duplicated transactional identifiers
- Committing offsets outside the transaction
- Weak replication settings under transactions
- Very long transactions that block readers
- One transaction per event
- Retrying after being fenced
- Never reconciling end-to-end results
Exactly-once is a property of a whole design, not a setting on one component. The weakest link, whether a consumer, an outside call or a replication setting, decides what you actually get.
A chain is only as strong as its weakest link. A perfect bank vault door does not protect the money if the back window is left open. Check every door and window of your pipeline.
26Real-World Scenarios
Five common situations show how the pieces combine in practice.
Scenario 1: Account balances in a payments platform
A stream application reads payment events, updates running balances and writes results to a balance topic. Repeats would corrupt balances, so it uses Kafka Streams exactly-once. Downstream services read at read-committed. Card network calls use idempotency keys derived from event identifiers.
Scenario 2: Inventory counts for an online store
Stock reservation and release events adjust counts per product. Exactly-once processing keeps counts accurate after restarts. A reconciliation job compares counts with the warehouse system each night to catch edge-case errors.
Scenario 3: Order events with an outbox
An order service saves orders in its database and needs to publish an event for each. It writes the event to an outbox table in the same database transaction. A relay publishes from the outbox with an idempotent producer, and consumers deduplicate by order event identifier.
Scenario 4: Enrichment pipeline that splits one event into several
One incoming event produces outputs on three topics. A transaction ensures either all three outputs appear or none, so downstream teams never see a partial result. Read-committed consumers see the three together.
Scenario 5: Analytics counters where approximation is fine
A website counts page views for dashboards. Small errors do not matter, so the team uses at-least-once with idempotent producers and skips the extra cost of transactions, choosing lower latency and simpler operations.
| Scenario | Guarantee Used | Key Additional Measure |
|---|---|---|
| Payment balances | Streams exactly-once | Idempotency keys for outside calls |
| Inventory counts | Exactly-once processing | Nightly reconciliation |
| Order publishing | Outbox with idempotent producer | Consumer deduplication |
| Multi-topic split | Transactions across topics | Read-committed consumers |
| Page view counters | At-least-once with idempotence | None needed beyond safe settings |
27Frequently Asked Questions
No. After a failure your code may run again on the same input. What is guaranteed is that the effects inside Kafka, the output events and the saved offsets, become visible exactly once.
Idempotence removes duplicates from retries within one producer session on a single partition. Transactions add atomic writes across partitions, atomic saving of consumer offsets, stable identity across restarts and fencing of zombies.
Only if you need output and input progress to commit together, atomic writes across partitions, or protection across restarts. For simple producers where retry duplicates are the only concern, idempotence is enough.
Otherwise they see events from open and aborted transactions. Read-committed makes them read only decided, committed data, which is what makes the atomicity visible to them.
A zombie is an old instance that has been replaced but is still running. Kafka gives the replacement a higher epoch for the same transactional identifier, and rejects writes with older epochs from the zombie.
No. Kafka transactions cover only Kafka topics and offsets. For databases, use the outbox pattern, store offsets with the data, or make the writes idempotent.
Commit and abort markers are control records that occupy offsets in the partition. Applications do not receive them, so offsets appear to skip numbers. Nothing is missing.
Through a single processing guarantee setting. The library then uses idempotent transactional producers, read-committed consumers and wraps output, state backup writes and offsets in transactions automatically.
There is some overhead from coordinator traffic and commit markers, and downstream readers see results after commits, adding latency. The impact depends on batch sizes, partition counts and commit interval, so measure with your own workload.
Read-committed consumers of the affected partitions are held back at the last stable offset until the transaction is finished. After the timeout, the coordinator aborts it and readers move on.
Recent versions support it for source connectors that are able to take part. For sink connectors, exactly-once behaviour depends on how the target system handles repeated or offset-tagged writes.
No. If your logic is naturally repeat-safe or small errors are acceptable, at-least-once with an idempotent design is simpler and cheaper. Choose exactly-once where duplicates or losses would produce wrong business results.
28Summary and Key Takeaways
Failures in distributed systems force a choice between losing events and repeating them. Kafka’s exactly-once semantics removes that choice inside Kafka by combining three ingredients: the idempotent producer, which lets brokers discard retried batches; transactions, which commit output and consumer offsets together and fence out zombie instances using epochs; and read-committed consumers, which see only decided data. Kafka Streams wraps all of it behind a single setting.
The guarantee has a clear boundary. It covers the read-process-write cycle inside Kafka, and it needs safe replication settings and cooperating consumers. For outside systems, pair it with idempotent design. If you remember one sentence, let it be this: exactly-once is about visible effects inside Kafka, and your design must make everything outside Kafka safe to repeat.
Key Takeaways
- Three semantics exist — at-most-once may lose, at-least-once may duplicate, and exactly-once shows each effect once within its scope.
- Idempotence removes retry duplicates — producer IDs and sequence numbers let the broker drop repeated batches, but only within one producer session per partition.
- Transactions join writes and offsets — a transaction commits output across partitions and the consumer’s input offsets as one all-or-nothing unit.
- Epochs fence zombies — a stable transactional identifier lets replacements take over, and older copies are rejected after the epoch is raised.
- Consumers must cooperate — only read-committed consumers, bounded by the last stable offset, are protected from open and aborted transactions.
- Kafka Streams automates it — one setting wraps output, state backup writes and offsets in transactions, with cost in latency and recovery time.
- Mind the boundary — outside databases, emails and services are not covered, so use idempotency keys, outboxes and offset-with-data patterns, and monitor, reconcile and test failures.