Exactly-Once Semantics in Apache Kafka, In Depth

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.

Simple Analogy

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.

i
Key Idea

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.

Level 1

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.

Level 2

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.

Level 3

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
        
FIG 1 — The order of saving progress and doing work decides between losing events and repeating them, and exactly-once ties them together
Simple Analogy

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.

GuaranteeCan Events Be Lost?Can Events Be Duplicated?Typical Use
At-most-onceYesNoMetrics and logs where gaps are tolerable
At-least-onceNoYesMost pipelines, especially with duplicate-safe consumers
Exactly-onceNoNo, within the guaranteed scopePayments, 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
        
FIG 2 — A lost acknowledgement makes the producer retry, creating a duplicate 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.

Source 1

Producer retry

Fixed by the idempotent producer, which lets the broker recognise repeats.

Source 2

Crash between output and offset

Fixed by transactions that commit output and offsets together.

Source 3

Zombie instance

Fixed by producer epochs that fence out old copies.

Source 4

Broker failure

Fixed by replication and safe acknowledgement settings.

Simple Analogy

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"]
        
FIG 3 — Kafka guarantees the read-process-write cycle inside Kafka, but not side effects on outside systems
Simple Analogy

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.

“Exactly-once is a promise about visible effects inside Kafka, not about how many times your code runs.”
!
Common Misconception

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
        
FIG 4 — The broker uses the producer ID and sequence number to drop a repeated batch
Simple Analogy

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
i
Practical Tip

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.

ConceptWhat It IsWhy It Matters
Producer IDNumber assigned by the broker to a producer sessionIdentifies whose batches these are
EpochCounter attached to the producer IDLets a newer session replace an older one
Sequence numberIncreasing number per producer and partitionLets the broker spot repeats and gaps
Dedup memoryRecent batches remembered by the brokerBounds how far back duplicates can be caught
Simple Analogy

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.

i
Insight

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.

Simple Analogy

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.

Simple Analogy

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.

Idea 1

Atomic writes

Writes to many partitions and topics become visible together or not at all.

Idea 2

Atomic offsets

The consumer’s input progress is committed inside the same transaction as the output.

Idea 3

Stable identity

The transactional identifier survives restarts so replacements can take over cleanly.

Idea 4

Fencing

Older copies with lower epochs are refused, so zombies cannot write.

!
Design Warning

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
        
FIG 5 — The coordinator records transaction state in a replicated log and talks to every partition involved
Simple Analogy

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.

!
Production Warning

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.

1

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.

2

Begin the transaction

The application marks the start of a transaction. This happens locally, and nothing is sent yet.

3

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.

4

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.

5

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.

6

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.

7

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
        
FIG 6 — The read-process-write loop wrapped inside a transaction that commits or aborts as one
i
Batching Tip

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
        
FIG 7 — The coordinator decides first, then writes commit markers to every partition involved

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.

Simple Analogy

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.

Prepare
decision recorded in the log
Markers
commit or abort written to each partition
Complete
final record closes the transaction
i
Good to Know

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
        
FIG 8 — The new instance’s higher epoch blocks the old instance from writing
Simple Analogy

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.

!
Common Mistake

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"]
        
FIG 9 — A read-committed consumer stops at the last stable offset and waits for open transactions to finish
Isolation LevelSees Open TransactionsSees Aborted EventsLatency
Read-uncommittedYesYesLowest
Read-committedNo, waits for the decisionNo, they are filtered outHigher, bounded by transaction length
Simple Analogy

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.

!
Critical Point

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

1

Disable automatic offset saving

The consumer must not commit offsets itself. Offset control moves to the transaction.

2

Read a batch at read-committed

The consumer reads only committed input, so it never builds output from data that may be aborted upstream.

3

Write outputs inside a transaction

The application writes results through a transactional producer.

4

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.

5

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.

Simple Analogy

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.

i
Insight

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
        
FIG 10 — Output, state backup and offsets commit together in one transaction in Kafka Streams

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
Simple Analogy

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"]
        
FIG 11 — Exactly-once is strongest inside Kafka and depends on the design of connectors and targets at the edges
Simple Analogy

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.

Simple Analogy

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.

i
Practical Tip

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.

AreaRequirementWhy
Producer acknowledgementsWait for all in-sync replicasAn event acknowledged by only the leader can vanish if that broker fails
Producer retriesEnabled, with idempotence onRetries are safe only because the broker removes repeats
Transactional identifierStable per logical worker, unique across workersEnables takeover and fencing
Consumer isolationRead-committed for every consumer of transactional outputPrevents reading aborted or open data
Consumer offset savingManaged through the transaction, not automaticKeeps offsets and output atomic
Topic replicationReplication factor of at least three with a minimum of two in-sync replicasSurvives a broker loss without losing committed data
Transaction logReplicated and with a safe minimum in-sync settingIt is the source of truth for transactions
Transaction timeoutLonger than typical processing, shorter than tolerable delayBalances false aborts against blocked readers
Unclean leader electionDisabledPrevents promoting an out-of-date replica that lacks committed data
Simple Analogy

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.

!
Common Mistake

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

Cost 1

Coordinator traffic

Extra requests to register partitions, add offsets and commit or abort each transaction.

Cost 2

Commit markers

A marker written to every partition touched by a transaction, so touching many partitions costs more.

Cost 3

Read-committed delay

Consumers wait for the commit decision before reading, so results arrive later.

Cost 4

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.

Simple Analogy

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.

i
Measure, Do Not Guess

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.

i
Pattern to Notice

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.

Simple Analogy

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.

SignalWhat It Tells YouTypical Response
Fenced producer errorsTwo instances share an identifier, or a zombie is activeCheck identifier assignment and deployment overlap
Transaction abort rateHow often batches fail and are redoneLook for timeouts, crashes or bad input
Commit latencyTime to finish a transactionCheck coordinator load and partition spread
Last stable offset lagA transaction holding readers backFind long-running or hung transactions
Consumer lag at read-committedHow far behind readers are, including transaction delayTune commit interval and consumer capacity
Out-of-order sequence errorsGaps in producer sequencesInvestigate data loss or unclean broker events
Transaction log healthHealth of the coordinator’s storageCheck replication and broker capacity
Streams restore timeTime to rebuild state after unclean stopsUse 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.

Simple Analogy

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

ANTI-PATTERN-01 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.

ANTI-PATTERN-02 Avoid
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.

ANTI-PATTERN-03 Avoid
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.

ANTI-PATTERN-04 Avoid
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.

ANTI-PATTERN-05 Avoid
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"]
        
FIG 12 — A decision path from business tolerance for errors to the appropriate guarantee
1

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.

2

Turn on the safe baseline

Use idempotent producers, replication with in-sync requirements and all-replica acknowledgements for every important topic.

3

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.

4

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.

5

Secure the edges

Add idempotency keys, outbox patterns or idempotent sink writes wherever data leaves Kafka.

6

Test failures on purpose

Kill instances mid-transaction, delay networks and restart brokers in a test environment, and confirm results stay correct.

i
Decision Hint

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
!
Reminder

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.

Simple Analogy

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.

ScenarioGuarantee UsedKey Additional Measure
Payment balancesStreams exactly-onceIdempotency keys for outside calls
Inventory countsExactly-once processingNightly reconciliation
Order publishingOutbox with idempotent producerConsumer deduplication
Multi-topic splitTransactions across topicsRead-committed consumers
Page view countersAt-least-once with idempotenceNone needed beyond safe settings

27Frequently Asked Questions

Q1Does exactly-once mean my code runs only once per event?

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.

Q2What is the difference between idempotence and transactions?

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.

Q3Do I need transactions if I already enable idempotence?

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.

Q4Why must consumers use read-committed?

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.

Q5What is a zombie and how does Kafka stop it?

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.

Q6Can Kafka transactions include my database update?

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.

Q7Why do I see gaps in offsets when using transactions?

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.

Q8How does Kafka Streams enable exactly-once?

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.

Q9Does exactly-once slow my pipeline down?

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.

Q10What happens if a transaction hangs?

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.

Q11Can I use exactly-once with Kafka Connect?

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.

Q12Is exactly-once always the right choice?

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.