Read-Process-Write Pattern Guarantees in Kafka
What really happens when an application reads messages from Kafka, transforms them, and writes results back, and how transactions bind the output records and the consumed offsets together so that a crash at any moment leaves the data correct.
Picture a mail-sorting clerk at a busy depot. She takes a letter from the incoming tray, stamps it, drops it into an outgoing bin, and then ticks it off on her checklist. Now imagine the fire alarm rings right after she drops the letter in the bin but before she ticks the checklist. When she returns, the checklist says the letter is still waiting, so she stamps and sends it again. The recipient gets two letters. If she had ticked the checklist first and the alarm rang before she dropped the letter in the bin, the letter would be lost instead.
Nearly every real Kafka application is this clerk. It reads from one topic, does some work, writes to another topic, and records how far it has read. The gap between “wrote the output” and “recorded the progress” is where duplicates and losses are born. This tutorial explains that gap, the tool Kafka offers to close it, the exact promises you get, and the exact promises you do not get, so you can design pipelines that stay correct under crashes, restarts, and network trouble.
1The Read-Process-Write Loop
A single loop sits at the heart of stream processing. Understanding its steps is the foundation for everything else.
The four steps
A read-process-write application, sometimes called consume-transform-produce, repeats the same four steps forever. First it reads a batch of records from one or more input topics using a consumer. Second it processes them: filtering, enriching, aggregating, or converting each one. Third it writes the results to one or more output topics using a producer. Fourth it commits its consumed offsets, which is the bookmark that says “everything before this point has been dealt with”.
Think of a chef working through a stack of order tickets. She reads a ticket, cooks the dish, sends it out to the dining room, and then spears the ticket on a spike to show it is done. The spike is the committed offset. If the spike is updated at the wrong moment, the kitchen either cooks a dish twice or forgets a dish entirely.
flowchart LR
IN["Input topic partitions"] -->|"1. poll records"| APP["Application: process"]
APP -->|"2. produce results"| OUT["Output topic partitions"]
APP -->|"3. commit offsets"| OFF["Offsets topic"]
OFF -->|"remembers position for restart"| IN
Why this pattern is everywhere
Enrichment
Read raw events, add customer or product details, and write enriched events for other teams to use.
Filtering and routing
Read a mixed stream and write each kind of event to its own topic, dropping the ones nobody needs.
Aggregation
Read individual events and write running totals, counts, or windows of statistics.
Format conversion
Read data in one shape and write it in another, for example turning raw logs into structured records.
Workflow steps
Read a command, perform a business step, and write an event that triggers the next step in a chain of microservices.
Stream processing frameworks
Libraries such as Kafka Streams are essentially large, well-organized read-process-write loops with state.
The loop touches three different places: the input log, the output log, and the offsets log. A crash can happen between any two of those touches. Most of this tutorial is about making the three touches behave like one single, indivisible action.
2Where It Breaks: The Failure Windows
Crashes are not rare accidents. In a long-running cluster, they are a routine part of life.
What the offset commit really means
A committed offset is a promise to your future self. If the application crashes and restarts, or if another instance takes over its work, it will resume reading exactly at the committed offset. Records before that point are considered finished, and records after it will be read again. So the timing of the commit relative to the write decides whether a crash produces duplicates or losses.
Commit first, write second
If the application commits the offset before the results are written, a crash between the two steps loses data. On restart, the application resumes after the record it never finished, so the output for that record never appears. This is called at-most-once behavior: nothing is repeated, but something may vanish.
Write first, commit second
If the application writes the results and only then commits, a crash between those steps causes repetition. On restart, the application resumes at the old offset and processes the same records again, producing the same output a second time. This is at-least-once behavior: nothing is lost, but something may appear twice.
sequenceDiagram
participant App as Application
participant Out as Output topic
participant Off as Offsets
Note over App: Order A: write then commit
App->>Out: Write result for records 10 to 19
Note over App: Crash here
App->>Off: Commit offset 20 (never happens)
Note over App: Restart resumes at offset 10
App->>Out: Write results for 10 to 19 again
Note over Out: Duplicates in the output
sequenceDiagram
participant App as Application
participant Out as Output topic
participant Off as Offsets
Note over App: Order B: commit then write
App->>Off: Commit offset 20
Note over App: Crash here
App->>Out: Write result for records 10 to 19 (never happens)
Note over App: Restart resumes at offset 20
Note over Out: Results for 10 to 19 are missing forever
| Order of steps | Crash in the gap causes | Name |
|---|---|---|
| Commit offsets, then write output | Lost output | At most once |
| Write output, then commit offsets | Repeated output | At least once |
| Write output and commit offsets as one atomic unit | Neither, the whole unit is redone or kept | Exactly once, within Kafka |
Many people think there must be a clever ordering of the two steps that avoids both problems. There is none. With two separate operations and a possible crash between them, one of the two failures is always possible. The only real cure is to make the two operations a single atomic action.
Other sources of trouble
Crashes are not the only trigger. A slow application can be declared dead by the group coordinator, causing its partitions to be handed to another instance while it is still working. The original instance, now a “zombie”, may then write output and try to commit, even though somebody else owns that work. Network glitches can make a producer retry a send, creating duplicates on their own. Any complete solution must handle all of these.
3Core Vocabulary You Need
Every term below is defined once here and then used consistently for the rest of the tutorial.
Consumer offset
A bookmark that records how far a consumer group has read in each partition. Stored in an internal topic.
Transaction
A group of writes, to several partitions, plus offset commits, that are made visible together or discarded together.
Transactional ID
A name chosen by the application that gives a producer a stable identity across restarts, so old instances can be fenced.
Transaction coordinator
A broker role that tracks the state of each transaction and drives it to completion.
Transaction log
An internal replicated topic in which the coordinator records every transaction state change.
Commit and abort markers
Special control records written into each involved partition to declare a transaction committed or aborted.
Isolation level
A consumer setting that decides whether it sees records of unfinished or aborted transactions or only committed ones.
Last stable offset
The position below which every transaction has finished. Committed-only readers never read beyond it.
Zombie
An old instance of an application that still runs after a newer instance has taken over its work.
Fencing
Rejecting requests from a zombie, using epochs and group generations, so it cannot corrupt the data.
Exactly-once semantics
The result of a read-process-write loop looks as if every input record was processed one time, even after failures.
Idempotent producer
A producer feature that removes duplicates caused by its own retries, and the base layer under transactions.
The phrase “exactly once” is easy to misread as a universal promise. In Kafka, it has a precise scope: the reading, the processing state that lives in Kafka, and the writing to Kafka topics. Later chapters explain exactly where the scope ends.
4The Big Picture: One Atomic Unit
The whole design fits into one sentence: treat the output records and the input offsets as a single transaction.
The central trick
Look again at the loop. The output records live in the output topic. The consumed offsets live in an internal topic, because Kafka stores consumer offsets as ordinary records in a log. Both are just writes to Kafka logs. That observation is the key. If Kafka can make several writes to several logs succeed or fail together, then the output and the offset commit can succeed or fail together too.
When you buy a house, the money transfer and the ownership change must happen together. A lawyer holds both sides in a sealed envelope and only releases them at the same moment. If anything goes wrong, both are returned untouched. A Kafka transaction is that lawyer, holding the output records and the offset bookmark in one envelope.
flowchart TD
A["Begin transaction"] --> B["Read records from input"]
B --> C["Process records"]
C --> D["Write results to output partitions, marked as part of the transaction"]
D --> E["Add consumed offsets to the same transaction"]
E --> F{"Everything succeeded?"}
F -->|"Yes"| G["Commit: outputs become visible and offsets advance together"]
F -->|"No, crash or error"| H["Abort: outputs stay hidden and offsets do not move"]
H --> I["Restart from old offsets and redo the work"]
The two outcomes
After a crash, exactly one of two things is true. Either the transaction committed, in which case the outputs are visible and the offsets have advanced, so the restarted application starts after those records and does not repeat them. Or the transaction did not commit, in which case the outputs are invisible to committed-only readers and the offsets have not moved, so the restarted application redoes the same records from the start. In neither case is there a duplicate visible to readers, and in neither case is there a loss.
What has to be true for this to work
Writes to several partitions can be grouped
The producer must be able to tell the cluster that a set of writes belongs together. This is what a transaction does.
Offsets can join the group
The consumed offsets must be committable as part of the same transaction, not through a separate side channel.
Readers respect the outcome
Downstream consumers must be able to ignore the writes of transactions that were aborted or are still open.
Zombies are stopped
An old instance must be prevented from writing into the same work after a new one has taken over.
Retries do not duplicate
Retries inside a transaction must not create duplicate records. The idempotent producer supplies this.
5Architecture and Components
Several parts of Kafka cooperate to make an atomic read-process-write possible.
flowchart LR
subgraph App["Your application instance"]
CON["Consumer: read committed, manual offsets"]
LOGIC["Processing logic"]
PRO["Transactional producer"]
end
subgraph Brokers["Kafka cluster"]
TC["Transaction coordinator"]
TL["Transaction log topic"]
IN["Input topic partitions"]
OUT["Output topic partitions"]
OFF["Consumer offsets topic"]
GC["Group coordinator"]
end
IN --> CON
CON --> LOGIC
LOGIC --> PRO
PRO -->|"begin, add partitions, add offsets, end"| TC
TC --> TL
PRO -->|"transactional writes"| OUT
PRO -->|"offsets as part of transaction"| OFF
CON --- GC
TC -->|"write markers"| OUT
TC -->|"write markers"| OFF
The roles, one by one
The consumer
Reads input records. It is configured to read only committed data, and to never commit offsets on its own, since the producer will commit them inside the transaction.
The transactional producer
Writes output, and also submits the consumed offsets to the transaction. It carries the transactional ID that gives it a stable identity.
The transaction coordinator
A broker chosen by hashing the transactional ID onto a partition of the transaction log. It owns the state of that producer’s transactions.
The transaction log
An internal, replicated topic where the coordinator records each state change, so the outcome survives coordinator failures.
The partition leaders
Store the transactional records and later the markers, and they help readers know which records are safe to deliver.
The group coordinator
Manages the consumer group and its offsets topic. It cooperates with the transaction machinery so offsets committed in a transaction only take effect when the transaction commits.
Believing the transaction covers “the application”. It covers only the writes that go through the transactional producer to Kafka topics and the offsets sent through it. Anything else your code touches, such as a database or a web service, sits outside the envelope.
6The Transactional ID: A Stable Identity
Transactions build on the idempotent producer’s identity, adding a name that survives restarts.
From a throwaway identity to a lasting one
A plain idempotent producer receives a fresh producer ID every time it starts, and its deduplication only covers that one session. A transactional producer sets a name, the transactional ID. When it starts, it contacts the transaction coordinator responsible for that name, and the coordinator returns the same producer ID as before, together with a higher epoch. The producer ID and epoch then travel with every batch the producer sends.
A hotel keeps your room but reissues your key card each time you lose it, always with a newer version number. Only the newest card opens the door. The old card, even if someone finds it, is useless. The transactional ID is your room number and the epoch is the card’s version.
What fencing does for a read-process-write loop
Suppose one instance of your application freezes for a long pause. The cluster decides it is gone, and a replacement instance starts, using the same transactional ID. The coordinator gives the replacement a higher epoch. Now the frozen instance wakes up and tries to write. Brokers see its lower epoch and reject the write. The old instance learns it has been fenced and must stop. Without this, two instances could both write results for the same input records.
sequenceDiagram
participant A as Old instance (epoch 4)
participant TC as Transaction coordinator
participant B as New instance
participant Br as Partition leader
Note over A: Long pause, cluster assumes it is gone
B->>TC: Initialize with the same transactional ID
TC-->>B: Same producer ID, epoch 5, abort any open transaction of epoch 4
Note over A: Wakes up
A->>Br: Write with epoch 4
Br-->>A: Rejected, epoch too old
Note over A: Fenced, must shut down
B->>Br: Write with epoch 5
Br-->>B: Accepted
Choosing the transactional ID
The name must be stable and unique per logical unit of work. Historically, a common rule was one transactional ID per input partition, so that when a partition moves between instances, the new owner uses the same name and fences the old owner. Newer designs, explained later, rely on the consumer group’s own membership information for fencing, which lets one producer serve many partitions. In either case, a random name on every start defeats the purpose, since the coordinator would treat each start as a completely new identity and nothing would be fenced or cleaned up.
| Choice | Effect | Verdict |
|---|---|---|
| Stable name tied to the unit of work | Restarts reuse identity, old instances are fenced | Correct |
| Random name per start | No fencing, orphaned open transactions linger until they time out | Wrong |
| Same name across different applications | Applications fence each other in confusing ways | Wrong |
7The Transaction Lifecycle, Step by Step
Follow one complete transaction from the first poll to the final marker.
Initialize once at startup
The producer registers its transactional ID with the coordinator, receives its producer ID and epoch, and any half-finished transaction from an older instance is settled, usually by aborting it.
Poll a batch of input
The consumer reads records from its assigned partitions. Nothing has been committed, and the consumer holds the batch in memory.
Begin the transaction
The producer marks the start locally. The coordinator learns about the transaction lazily, when the first partition is added.
Process and send
For each result record, the producer first registers the target partition with the coordinator if it is new to this transaction, then sends the record. The coordinator writes the partition list to the transaction log before the data is allowed to land.
Send the consumed offsets
The application passes the offsets it has processed, together with the consumer group’s membership information, to the producer. The offsets topic partition is registered with the transaction, and the offsets are written as pending.
Commit the transaction
The producer asks the coordinator to end the transaction as committed. The coordinator drives the two-phase commit described in a later chapter.
Markers make it visible
Commit markers are written to every partition involved. Now committed-only readers can see the outputs, and the offsets take effect. The loop returns to step two.
sequenceDiagram
participant C as Consumer
participant P as Transactional producer
participant TC as Coordinator
participant O as Output partition leader
participant G as Offsets partition leader
C->>P: Batch of input records
P->>P: Begin transaction
P->>TC: Add output partition to transaction
TC->>TC: Record partition in transaction log
P->>O: Send result records (flagged transactional)
P->>TC: Add offsets partition to transaction
P->>G: Send consumed offsets as pending
P->>TC: End transaction: commit
TC->>TC: Log prepare-commit
TC->>O: Write commit marker
TC->>G: Write commit marker
TC->>TC: Log complete-commit
TC-->>P: Commit acknowledged
The coordinator is only told about a partition the first time it appears in a transaction, and only told about the transaction when the first partition is added. This saves round trips for transactions that end up empty, and keeps the cost of a transaction proportional to the number of distinct partitions it touches.
What the application code actually does
From the programmer’s side, the shape is short: initialize once, then in a loop, poll, begin, send results, send offsets, commit. If any step throws, the code aborts the transaction and rewinds the consumer to the last committed positions so the same input is read again. The rest of the machinery is invisible. This simplicity is deliberate, but it hides a lot of coordination, which is why understanding the internals pays off during incidents.
Where the time goes in one transaction
It helps to picture a transaction as a small schedule of waits. The application spends most of its time polling and processing, which is ordinary work that any consumer does. Then come the coordination steps: registering each new partition, sending the offsets, and finally the commit request. The commit call returns only after the coordinator has recorded its decision and the markers are on their way, so the caller pays for a replicated write to the transaction log and, in many versions, for the marker writes as well. After the commit returns, committed-only readers still need a moment before they can read the new records, because the last stable offset advances only when the markers land in the partition.
| Phase | Who waits | What dominates the delay |
|---|---|---|
| Poll and process | The application | Batch size and the cost of the processing logic |
| Register partitions and offsets | The producer, briefly | Network round trips to the coordinator, once per new partition |
| Commit request | The producer | Replicated write of the commit decision to the transaction log |
| Marker writing | The coordinator, in the background | Number of involved partitions and their leaders’ responsiveness |
| Visibility for readers | Downstream consumers | Time until the last stable offset passes the new records |
Seeing the schedule laid out clarifies two common tuning choices. Reducing the number of distinct partitions a transaction touches shrinks both the registration and the marker phases. Keeping the coordinator brokers lightly loaded shrinks the commit phase. The processing phase, which is usually the biggest slice, is a matter for ordinary application profiling.
8Sending Offsets Inside the Transaction
This single step is what separates a true read-process-write guarantee from a mere batch of transactional writes.
Why offsets must not be committed separately
If the consumer committed its own offsets in the usual way, that commit would be a separate action, outside the transaction. We would be back to the two-step problem: output in one place, bookmark in another, and a crash between them. To avoid this, the consumer’s automatic committing is switched off, and the application hands its offsets to the producer, which includes them in the transaction.
Instead of the chef spearing the ticket on the spike herself, she hands both the finished dish and the ticket to the head waiter, who carries them to the dining room together. Either the dish and the ticket both arrive, or neither does.
What happens to those offsets
The offsets are written to the internal offsets topic, like any offset commit, but flagged as belonging to an open transaction. Until the commit marker arrives, the group coordinator treats them as pending, so a restarted or replacement consumer still sees the old committed positions. When the commit marker is written, the pending offsets become the committed ones. If an abort marker is written instead, the pending offsets are discarded and the old positions remain.
flowchart TD
A["Offsets for records 10 to 19 sent inside transaction"] --> B["Stored as pending in offsets topic"]
B --> C{"Marker arrives"}
C -->|"Commit marker"| D["Pending offsets become committed, group position moves to 20"]
C -->|"Abort marker"| E["Pending offsets discarded, group position stays at 10"]
D --> F["Restart resumes at 20, no duplicates"]
E --> G["Restart resumes at 10, work is redone, aborted output stays hidden"]
Including the group membership information
Modern clients send the offsets together with the consumer group’s metadata: the group name, the current generation number, and the member identity. The group coordinator checks these when it receives the offsets. If the generation is old, because a rebalance has happened and the partitions have moved to someone else, the request is rejected. This is the mechanism that fences zombies at the level of the consumer group, and it is what allows one producer to serve many partitions safely.
| Design generation | Fencing based on | Producers needed |
|---|---|---|
| Original design | Transactional ID tied to each input partition | One producer per input partition, many producers per application |
| Improved design | Consumer group generation and member information | One producer per application thread |
Leaving automatic offset commits switched on while using transactions. The consumer would then advance its bookmark on its own schedule, unrelated to any transaction, and duplicates or losses come right back. Always disable automatic commits for a transactional read-process-write loop.
9Two-Phase Commit Inside Kafka
The coordinator settles the fate of every transaction with a careful two-step protocol.
The problem it solves
A transaction may span many partitions, each led by a different broker. The commit must not be half done: no partition may show the outputs as committed while another still shows them as pending forever. The coordinator therefore uses the classic idea of two phases. Phase one is “decide and record the decision durably”. Phase two is “tell everyone”. Because the decision is recorded first, a crash during phase two can always be resumed.
The states of a transaction
Empty
The producer has no open transaction. This is the resting state between transactions.
Ongoing
Partitions and offsets are being added. The transaction clock is ticking against its timeout.
Prepare commit
The producer asked to commit. The coordinator has durably recorded the decision, and can no longer change its mind.
Prepare abort
Either the producer asked to abort or the transaction timed out. The abort decision is durably recorded.
Complete commit
All commit markers have been written to every involved partition. The transaction is finished.
Complete abort
All abort markers have been written. The transaction is finished and its records stay hidden from committed-only readers.
flowchart LR
E["Empty"] -->|"first partition added"| O["Ongoing"]
O -->|"producer commits"| PC["Prepare commit"]
O -->|"producer aborts or timeout"| PA["Prepare abort"]
PC -->|"all commit markers written"| CC["Complete commit"]
PA -->|"all abort markers written"| CA["Complete abort"]
CC --> E
CA --> E
Why a crash in phase two is harmless
Suppose the coordinator has recorded “prepare commit” and written markers to two of three partitions when it crashes. Another broker takes over as coordinator, reads the transaction log, and sees that this transaction is in the prepare-commit state. It knows the decision was to commit, so it finishes the job: it writes the missing marker and then records “complete commit”. Writing a marker twice is harmless, because markers are idempotent facts. The decision never flips, so the transaction never ends half committed and half aborted.
A wedding officiant writes “married” in the register first, and only afterward tells the family and the newspapers. If the officiant faints while phoning the newspapers, a colleague reads the register, sees the marriage is recorded, and finishes the phone calls. The marriage itself is never in doubt.
The order matters more than any other detail: the decision is written to the replicated transaction log before any marker is written to any partition. That ordering is what makes the whole protocol recoverable.
10Control Records and Markers in the Log
Transactions leave visible footprints inside the partition logs. Knowing them makes debugging far easier.
Data batches flagged as transactional
Every record batch that belongs to a transaction carries a flag in its header saying “this is transactional”, plus the producer ID and epoch. The broker writes it to the log like any other batch, immediately. There is no hidden staging area. Transactional data lands in the partition right away, and the question of whether readers may see it is answered later by the marker.
Control batches
When the transaction ends, the coordinator asks each partition leader to append a small special batch called a control batch. It holds a single control record of type commit or abort, again carrying the producer ID and epoch. Applications never see control records, since the consumer library filters them out, but they occupy offsets in the log.
flowchart LR
A["Offset 100: normal record"] --> B["Offset 101 to 103: transactional batch, producer 42"]
B --> C["Offset 104: normal record"]
C --> D["Offset 105 to 106: transactional batch, producer 42"]
D --> E["Offset 107: commit marker for producer 42"]
E --> F["Offset 108: normal record"]
| Item in the log | Visible to a committed-only reader? | Notes |
|---|---|---|
| Normal, non-transactional batch | Yes, once below the stable point | Not affected by transactions |
| Transactional batch with commit marker | Yes, after the marker is written | Records appear in offset order |
| Transactional batch with abort marker | No, filtered out | The data stays on disk until retention removes it |
| Transactional batch, no marker yet | No, held back | Blocks the stable point, so later records also wait |
| Control record | Never delivered to the application | Consumes an offset, creating visible gaps |
A consumer that reads a transactional topic may see the offset jump by one where a marker sits, or by more where aborted data was skipped. Code that assumes consecutive offsets, such as a check that each offset is the previous one plus one, will misfire. Treat offsets as positions, not as a dense counter of records.
Aborted data stays on disk
An aborted transaction does not delete anything. Its records remain in the log until normal retention removes them, and brokers keep a small index that lists the ranges of aborted transactions so readers can skip them. This design keeps the write path fast, because aborts are cheap: writing a single marker is enough, with no need to rewrite or erase data.
11The Consumer Side: Reading Only Committed Data
The guarantee is only as strong as the readers that respect it. Here is how a consumer filters the log.
Two isolation levels
Read committed
- Delivers only committed transactional data and normal data
- Skips aborted data completely
- Stops at the last stable offset, waiting for open transactions
- Required for downstream readers of exactly-once output
Read uncommitted
- Delivers everything, including aborted and open data
- Lowest latency, no waiting for transactions
- Downstream sees duplicates and aborted results
- This is the default, so it must be changed deliberately
The consumer default is to read uncommitted data. A team may build a perfect exactly-once producer stage, and then read its output with a default consumer, which happily delivers aborted and in-progress records. The guarantee is lost at the reading step. Every consumer of transactional output needs to be told to read committed data.
The last stable offset
A committed-only reader may only read up to the last stable offset, often shortened to LSO. This is the position just before the earliest transaction that is still open in that partition. Anything beyond it might belong to an unfinished transaction, and the broker will not hand it over yet. It is different from the high watermark, which only reflects replication. The LSO is never above the high watermark, and equals it when no transactions are open.
A newspaper only prints stories that the editor has signed off. If one story near the front of the queue is still under review, the printing of that story’s page waits, and the stories behind it wait too, even if they are already approved, because the pages must go out in order.
flowchart TD
A["Consumer requests data from partition"] --> B["Broker returns records up to last stable offset"]
B --> C["Response also lists aborted transaction ranges in the fetched span"]
C --> D{"Consumer reads next batch"}
D -->|"Batch belongs to an aborted transaction"| E["Skip batch"]
D -->|"Control record"| F["Consume the marker and continue"]
D -->|"Committed or normal batch"| G["Deliver to application"]
How aborted data is skipped
When the broker answers a fetch, it can also include the list of aborted transactions within the returned range, taken from its aborted-transaction index. The consumer library keeps track of which producer IDs currently have an aborted range covering the position it is reading. When it meets a batch from such a producer inside such a range, it drops the batch silently, and continues. The application never sees the records.
The cost: waiting for slow transactions
Because the LSO stops at the earliest open transaction, one slow or stuck transaction on a partition delays everything behind it for committed-only readers, even records written by other, healthy producers. Downstream lag grows, although the partition itself is healthy. This is why short transactions matter, and why hanging transactions are treated as incidents.
12Ordering and Visibility Guarantees
Precise promises are easier to trust than vague ones. Here is what you can count on.
| Question | Answer with transactions and committed-only readers |
|---|---|
| Is the output of a transaction atomic across partitions? | Yes. All outputs become visible together on commit, or none do on abort. |
| Are consumed offsets committed atomically with the output? | Yes, if they are sent through the producer as part of the transaction. |
| Is order preserved within one partition? | Yes. Records appear in offset order, and committed data from one transaction appears in the order it was sent. |
| Is there an order between different partitions? | No. As always in Kafka, ordering is only per partition. |
| Can a reader see output before the commit marker? | Not a committed-only reader. A default reader can. |
| Can two transactions interleave in a partition? | Yes, if they come from different producers. Their records may be mixed in the log, and each is resolved by its own marker. |
| Do readers see transactions in commit order? | They see data in offset order, which is the order data was written, and it may differ from the order in which transactions committed. |
sequenceDiagram
participant PA as Producer A
participant PB as Producer B
participant L as Partition log
participant R as Committed-only reader
PA->>L: Offset 10 (transaction A)
PB->>L: Offset 11 (transaction B)
PB->>L: Commit marker B at offset 12
Note over R: Cannot read past offset 9, transaction A still open
PA->>L: Commit marker A at offset 13
Note over R: Now reads offsets 10 and 11 in log order
The reader in the diagram had to wait for transaction A, even though B was already committed and its data was written after A’s data. Delivery order equals log order. This is a consistent and simple rule, but it means the latency of one transaction can affect the visibility of another.
Isolation is read committed, not serializable
Kafka transactions give you atomic visibility of writes, and protection against reading uncommitted data. They do not lock records or prevent two different applications from processing the same business entity concurrently. If two independent producers write conflicting facts about the same key in different transactions, both may commit. Business-level conflicts must be prevented by partitioning the work so that each key has a single writer, which the standard keyed-partition design does naturally.
13The Abort Path: Handling Errors Safely
A transaction that cannot finish must be undone cleanly. This chapter shows what that looks like in the loop.
When an abort happens
Processing fails
The application code throws an exception on a record, for example because of a bug or unexpected data. The application decides to abort, so the partial output is never shown to committed-only readers.
A send fails permanently
The producer cannot deliver a record, for example because the partition is unavailable for too long. The transaction cannot commit as planned and must be aborted.
A rebalance takes the partitions away
The group reassigns the input partitions, so the work in progress no longer belongs to this instance. The instance aborts and starts over on its new assignment.
The transaction times out
The transaction has been open longer than its allowed time. The coordinator itself aborts it, without waiting for the application.
What the application must do after an abort
Aborting the transaction is only half the job. The consumer has already read past those records in memory, so if the application simply continued, it would skip them. After an abort, the application must rewind each input partition to its last committed offset, so the same records are read again in the next loop. Then, when the retry succeeds, the output appears exactly once for committed-only readers, since the earlier attempt’s output is hidden behind an abort marker.
flowchart TD
A["Error during transaction"] --> B["Abort transaction"]
B --> C["Coordinator writes abort markers to each involved partition"]
C --> D["Pending offsets discarded, aborted output hidden"]
D --> E["Consumer seeks each partition back to last committed offset"]
E --> F["Next loop re-reads the same records"]
F --> G{"Retry succeeds?"}
G -->|"Yes"| H["Commit: output visible once, offsets advance"]
G -->|"No, same error again"| I["Poison record: needs skipping or a dead letter path"]
Poison records and infinite loops
If one particular input record always causes a failure, then abort-and-retry becomes an endless loop, with the same record failing again and again while the partition makes no progress. A robust design catches errors for individual records and routes bad records to a separate dead letter topic, as part of the same transaction, so the loop can move past them. The dead letter write is transactional too, so it appears once, and the offset advances past the bad record in the same commit.
Some errors, such as being fenced by a newer instance, mean this producer must never be used again and the instance should shut down. Others, such as a temporary abortable error, allow abort-and-retry with the same producer. Read the documentation for your client version, and treat fencing errors as fatal, not as something to retry around.
14Rebalances and Zombie Fencing
Partitions move between instances. The guarantee must survive every move.
Why rebalances are dangerous
In a consumer group, input partitions are distributed among instances. When an instance joins, leaves, or is declared dead, the group rebalances and partitions move. During that moment, the previous owner may still be running: it may be mid-transaction, or frozen, or slow. If both the old and the new owner processed the same partition at once, both would write output for the same input, and the results would be duplicated.
Fence by identity, or fence by group generation
Original approach: one transactional ID per input partition
The new owner of a partition uses the same transactional ID as the old one, so the coordinator bumps the epoch and fences the old owner. This works, but an application needs one producer per assigned partition, which multiplies connections, buffers, and coordinator work.
Improved approach: use the group generation
The producer sends the consumer’s group metadata along with the offsets. The group coordinator rejects offset commits from a member whose generation is out of date. Fenced instances cannot commit offsets, so their transactions can never commit successfully. One producer can serve all partitions of the thread.
Protection against pending offsets
The improved design also protects against a subtle race: a new owner must not read the offsets of a partition while an old owner’s transaction covering those offsets is still open. Newer clients and brokers make the new owner wait until such pending offsets are resolved.
sequenceDiagram
participant A as Instance A (old owner)
participant GC as Group coordinator
participant B as Instance B (new owner)
participant TC as Transaction coordinator
Note over A: Slow, misses heartbeats
GC->>GC: Rebalance, generation 7 to 8, partition moves to B
B->>GC: Read committed offsets for the partition
GC-->>B: Waits if A has pending offsets in an open transaction
A->>GC: Send offsets with generation 7 in transaction
GC-->>A: Rejected, generation is old
Note over A: Transaction cannot commit, A aborts
B->>TC: Begin own transaction, work proceeds on the partition
Cooperative rebalancing and transactions
Modern group protocols can move only some partitions at a time, so healthy instances keep working through a rebalance. Transactions fit this model: when a partition is taken away, the instance must finish or abort any open transaction covering it before continuing, and it must not send offsets for partitions it no longer owns. The safest simple behavior, and the one frameworks use, is to abort the current transaction whenever the assignment changes, and start fresh.
If you use a stream processing library, it wires the rebalance callbacks, aborts, and rewinds for you. If you write the loop by hand, you take on that duty, and it is the most common place for hand-written loops to be subtly wrong.
15Timeouts and Hanging Transactions
A transaction that never ends is the most disruptive thing that can happen to a committed-only reader.
The transaction timeout
Every transaction has a time budget, set by the producer, and capped by a broker-side maximum. If the budget runs out while the transaction is still ongoing, the coordinator aborts it on its own. This protects readers: without a timeout, a producer that crashed mid-transaction and never came back would hold the last stable offset back forever. The timeout should be longer than the longest normal processing time for one batch plus a safety margin, but not so long that a real crash blocks readers for many minutes.
| Timeout choice | If too short | If too long |
|---|---|---|
| Transaction timeout | Healthy but slow batches get aborted and retried, wasting work and adding latency | A crashed producer blocks committed-only readers until the timeout passes |
| Session and poll timeouts of the consumer group | Slow batches trigger unnecessary rebalances | Dead members hold their partitions for long |
Hanging transactions
A hanging transaction is one that has data in a partition but no marker, and whose coordinator does not know about it, or cannot complete it. In older versions, rare races between late writes and completed transactions could produce such a state. The last stable offset then stays stuck, and downstream readers lag steadily, even though every broker looks healthy. Recent protocol improvements close most of these races by having the producer and the coordinator agree more strictly on which partitions belong to which transaction, but operators should still know how to spot and fix the problem.
flowchart TD
A["Downstream lag grows while partition is healthy"] --> B{"Is last stable offset far behind high watermark?"}
B -->|"No"| C["Look elsewhere: slow consumer or processing"]
B -->|"Yes"| D["List open transactions and producers on the partition"]
D --> E{"Is the oldest open transaction still live and progressing?"}
E -->|"Yes"| F["Wait or shorten transactions in the producing application"]
E -->|"No, orphaned"| G["Use the transactions tool to find hanging transactions and abort them"]
G --> H["Marker written, last stable offset advances, lag recovers"]
Kafka ships a transactions tool that can list transactional IDs, describe their state, list active producers on a partition, find hanging transactions, and abort them. Knowing it exists before an incident saves precious time, and it should be part of every operations runbook for clusters that use transactions.
Forcing an abort on a live, healthy transaction discards work that was almost done. Always confirm that the owner is truly gone before using the tool, and record what you did in the incident notes.
16The Precise Guarantee, Stated Carefully
Marketing phrases blur edges. Engineers need the exact statement.
What Kafka promises
For a read-process-write application that reads from Kafka topics, writes only to Kafka topics, uses a transactional producer, sends its consumed offsets through that producer, and reads with committed-only isolation downstream, the following holds. The effect of processing each input record on the output topics is visible to committed-only readers exactly once, even if instances crash, restart, are fenced, or if brokers fail over. The consumed offsets advance if and only if the corresponding output is committed.
Atomicity
Outputs to multiple partitions and topics, together with offsets, are all visible or none are visible.
No loss
An input record whose offset was committed has its output committed. Uncommitted input is redone.
No visible duplicates
Retries, restarts, and zombies cannot produce repeated committed outputs for the same input.
Durability
A committed transaction survives broker failures, given a proper replication setup with strong acknowledgements.
What the promise depends on
Correct configuration
Transactional ID set, automatic offset commit off, committed-only readers, strong replication and acknowledgement settings.
Deterministic enough processing
If processing depends on the clock, random numbers, or an outside lookup, then redoing an aborted batch may produce a different result than the aborted attempt. That is fine for correctness of the final committed output, since only one attempt commits, but it means the outcome is one of several possible ones.
Everything stateful is inside Kafka
State that the processing uses must itself be stored in Kafka topics that participate in the transaction, such as change-log topics.
Downstream respects isolation
The readers of the output must use committed-only isolation, or the atomic visibility is lost.
Common misreadings of the guarantee
“Each message is delivered once”
Messages are still read again after an abort. What happens once is the visible effect of processing on the committed output.
“The application code runs once per record”
Your code may run several times for the same record, because aborted attempts are redone. Only one attempt’s results become visible.
“Nothing is ever written twice to the log”
Aborted attempts leave real records on disk. They are hidden from committed-only readers, not erased.
“It works across clusters”
A transaction lives inside one cluster. Copying data between clusters is a separate pipeline with its own guarantees to design.
The second myth deserves extra attention, because it has practical consequences. If your processing logic has side effects that are not idempotent, such as incrementing an in-memory counter that is later exposed through a web page, then a redone attempt will apply the side effect again. Keep the logic inside the transaction free of such effects, or confine them to state that is itself rebuilt from committed data after every abort.
17What Is Not Covered: The Outside World
The transaction boundary is a fence around Kafka. Everything on the other side of the fence needs its own protection.
Situation
The processing step writes a row to a relational database, and then the transaction is aborted or the instance crashes before the commit.
Why It Is Not Covered
The database write is not part of the Kafka transaction. It cannot be rolled back by an abort marker. On retry, the step writes the row again.
What To Do
Make the database write idempotent using a unique key derived from the input record, or route the change through a transactional outbox.
Situation
The processing step calls a payment gateway, sends an email, or triggers a web hook.
Why It Is Not Covered
Side effects on external systems cannot be undone by Kafka, and repeated attempts repeat the effect.
What To Do
Send an idempotency key with each external call, so the other system can recognize repeats, or write a command event and let a dedicated, carefully designed service perform the side effect.
Situation
A downstream consumer reads the output with the default isolation level.
Why It Is Not Covered
The consumer sees aborted and in-progress records, which breaks the atomic visibility.
What To Do
Set committed-only isolation on every reader of transactional topics, and enforce it in shared client templates.
Situation
The input topic itself contains duplicates because an upstream producer was not idempotent or restarted and resent events.
Why It Is Not Covered
The transaction guarantees that each input record is processed once. If the same business event exists as two records, both are processed once.
What To Do
Fix the upstream producer, or deduplicate by a business key inside the processing step with state stored in Kafka.
Kafka can make Kafka-to-Kafka processing exactly once. For anything that leaves Kafka, the honest guarantee is “at least once, with deduplication”, and the deduplication has to be built into the receiving side.
18Patterns for Reaching Outside Kafka
Since the fence is real, engineers have developed reliable ways to cross it.
Pattern one: the idempotent sink
The receiving system accepts the same write repeatedly without changing the result. A database table with a unique key derived from the input record’s topic, partition, and offset, or from a business identifier, turns a repeated insert into a harmless no-op or an update to the same values. A search index that stores documents by identifier behaves the same way. This is the simplest, most robust pattern, and should be your first choice when the target supports it.
Pattern two: store the offset with the result
When the target is a transactional database, write the result and the input offset in one database transaction, and on restart, read the stored offset from the database and seek the consumer to it. The database becomes the authority on progress. This inverts the usual arrangement, but it gives atomicity between the result and the position without needing Kafka transactions at all.
Pattern three: the transactional outbox
A service must change its database and also publish an event. Doing both directly risks doing one and failing the other. Instead, the service writes the event into an outbox table in the same database transaction as the business change. A relay process later reads the outbox and publishes to Kafka, marking rows as sent. Duplicates are still possible if the relay crashes after publishing, so it uses an idempotent producer and gives each event a unique identifier that consumers can use to spot repeats.
flowchart LR
A["Service handles request"] --> B["One database transaction"]
B --> C["Update business tables"]
B --> D["Insert event into outbox table"]
D --> E["Relay reads new outbox rows"]
E --> F["Idempotent producer publishes to Kafka"]
F --> G["Relay marks rows as sent"]
F --> H["Consumers deduplicate by event identifier"]
Pattern four: command events and dedicated actors
Rather than performing an external side effect inside the loop, write a command event, such as “send this email with this identifier”, to a topic. A small dedicated service reads those commands and performs the effect using the identifier as an idempotency key with the external system. This keeps the main loop purely Kafka-to-Kafka, exactly once, and isolates the difficult external behavior into one place that can be designed and tested carefully.
| Pattern | Best for | Main requirement |
|---|---|---|
| Idempotent sink | Databases and indexes with unique keys | Stable identifier per record |
| Offset stored with result | Transactional databases as the final target | Seek logic at startup and rebalance |
| Transactional outbox | Services that update a database and must publish events | A reliable relay and consumer deduplication |
| Command events | Emails, payments, calls to third parties | Idempotency keys supported by the external system |
Some systems offer no way to recognize repeats. In such cases, no amount of Kafka machinery can give exactly-once effects. You must choose between the risk of a lost effect and the risk of a repeated one, and design compensation, such as refunds or corrections, for the rare wrong outcome.
19Stateful Processing and Stream Processing Libraries
When the processing step remembers things, the memory must be part of the guarantee too.
The state problem
Many pipelines keep state: a running count per customer, the latest value per key, a window of recent events. If the state is stored in a local database on the application’s disk, a crash and a redo would apply the same update to the state twice, or lose it. Yet the whole point of the transaction was that processing effects happen exactly once.
The change-log solution
Stream processing libraries keep local state for speed, but every change to it is also written to a compacted change-log topic in Kafka. That write is part of the same transaction as the output and the offsets. After a crash, the state is rebuilt by replaying the committed part of the change-log. Aborted updates are hidden by their abort marker, so the rebuilt state contains exactly the effects of committed work.
flowchart TD
A["Read input record"] --> B["Update local state store"]
B --> C["Write state change to change-log topic"]
C --> D["Write output record"]
D --> E["Add input offsets"]
E --> F{"Commit transaction"}
F -->|"Commit"| G["State, output, and offsets all durable together"]
F -->|"Abort"| H["Change-log and output hidden, local state restored from committed change-log"]
The library setting
In Kafka Streams, one configuration value selects the processing guarantee. The modern exactly-once option, often called version two, uses one transactional producer per stream thread, relies on the consumer group’s metadata for fencing, and requires reasonably recent brokers. An older option used one producer per task and is deprecated. The at-least-once option is the default and skips the transaction overhead.
| Option | Producers | Guarantee | Commit interval default |
|---|---|---|---|
| At least once | Ordinary idempotent producer | No loss, possible duplicates | Long, tens of seconds |
| Exactly once, original | One transactional producer per task | Atomic, but heavier on resources | Short, fractions of a second |
| Exactly once, version two | One transactional producer per thread | Atomic, lighter and more scalable | Short, fractions of a second |
Because committed-only readers wait for the transaction to finish before seeing output, the library commits frequently in exactly-once mode. This keeps end-to-end latency low, at the cost of more transactions per second. Tuning the interval trades latency against overhead.
Connectors
Data integration tools sit at the fence. A source connector reading from an external system can use transactions to write its records and its own progress markers together, with support in recent releases. A sink connector writing to an external system generally offers at-least-once delivery, and relies on idempotent sinks for exactly-once effects, following the patterns of the previous chapter.
Hand-written loop or library?
Writing the loop yourself gives full control, but it also makes you responsible for every subtle duty: aborting on rebalance, rewinding after an abort, treating fencing as fatal, closing producers cleanly, and rebuilding state. A stream processing library has already solved these problems, and has been tested against thousands of failure combinations. Unless your logic is very simple or your needs are unusual, the library is usually the safer choice.
| Question | Hand-written loop | Stream processing library |
|---|---|---|
| Control over each step | Complete | Guided by the library’s model |
| Rebalance and abort handling | You write and test it | Built in and widely exercised |
| State management | You design change-log topics and recovery | Provided, with change-log topics and restore logic |
| Effort to reach exactly-once | High, and easy to get subtly wrong | A single configuration switch |
| Best for | Small, stateless transformations with special needs | Stateful processing, joins, windows, and larger pipelines |
Start with a library in exactly-once mode. Move to a hand-written loop only when you can name a concrete requirement the library cannot meet, and when you have the time to test the failure paths as thoroughly as the library’s own authors did.
20A Worked Example With Real Numbers
A small, concrete story makes the abstract rules easy to remember.
The setting
An enrichment service reads order events from partition 3 of an input topic and writes enriched orders to partition 1 of an output topic. Its consumer group has committed offset 200 for the input partition. It uses a transactional producer with a stable transactional ID. The service polls and receives records at offsets 200 to 204.
Attempt one: a crash mid-transaction
The service begins a transaction, enriches the five records, and sends five output records, which land in the output partition at offsets 700 to 704. It then sends the offset 205 as the position to commit, which is stored as pending. Before it can call commit, the process is killed.
| What exists after the crash | Where | Visible to committed-only readers? |
|---|---|---|
| Five enriched records, offsets 700 to 704 | Output partition | No, no marker yet, they sit beyond the last stable offset |
| Pending offset 205 | Offsets topic | No, still pending, group position remains 200 |
| Open transaction | Transaction log | Ongoing until the timeout or a new instance takes over |
Attempt two: the replacement instance
A replacement instance starts with the same transactional ID. The coordinator sees an old open transaction, aborts it, and writes an abort marker into the output partition at offset 705 and into the offsets partition. The replacement reads the committed group position, which is still 200. It polls the same five records, processes them, and begins a new transaction. This time the transactional output lands at offsets 706 to 710, the pending offset 205 is stored again, and the commit succeeds. Commit markers are written, the group position becomes 205, and the output at 706 to 710 becomes visible.
sequenceDiagram
participant A as Instance A
participant TC as Coordinator
participant O as Output partition
participant B as Instance B
A->>O: Enriched records at 700 to 704 (transaction 1)
A->>TC: Offsets pending, position 205
Note over A: Crash, no commit request sent
B->>TC: Initialize with same transactional ID
TC->>O: Abort marker at 705
TC-->>B: New epoch, old transaction aborted
B->>O: Enriched records at 706 to 710 (transaction 2)
B->>TC: Offsets, position 205, then commit
TC->>O: Commit marker at 711
Note over O: Committed-only readers see 706 to 710 once
What a downstream reader observes
A committed-only reader of the output partition reads the offsets in order. It skips 700 to 704, since they belong to an aborted transaction, consumes the control record at 705 silently, delivers the five records at 706 to 710, and consumes the commit marker at 711. From the application’s viewpoint, five enriched records appeared exactly once. The offsets jumped from 699 straight to 706 in what the application receives, which is the visible sign of the abort. A default reader, in contrast, would have delivered the records at 700 to 704 as well, producing ten records instead of five.
The crash left five stray records physically in the log. Their harmlessness depends entirely on readers ignoring them. The guarantee lives in the combination of writer behavior and reader behavior, never in the writer alone.
21The Configuration Checklist
A handful of settings on three components decide whether the guarantee really holds.
| Component | Setting | Required value or guidance |
|---|---|---|
| Producer | transactional.id | Stable, unique per logical unit of work, never random |
| Producer | enable.idempotence | True, and implied by a transactional ID |
| Producer | acks | All in-sync replicas must acknowledge |
| Producer | transaction.timeout.ms | Longer than the slowest healthy batch, and within the broker maximum |
| Consumer | enable.auto.commit | False, the producer commits offsets inside the transaction |
| Consumer | isolation.level | Read committed, for the input and for every downstream reader |
| Consumer | max.poll.interval.ms and session timeout | Comfortably longer than one batch, so slow batches do not cause rebalances |
| Broker | transaction.state.log.replication.factor | Three in production, so the transaction log survives broker loss |
| Broker | transaction.state.log.min.isr | Two, so transaction state changes require a majority of replicas |
| Broker | transaction.max.timeout.ms | Upper bound on what producers may ask for |
| Topics | Replication factor and min.insync.replicas | Three and two, so committed data survives a failover |
| Cluster | Unclean leader election | Disabled, so out-of-date replicas never lead |
Things that quietly break the guarantee
Default isolation on a reader
The most common failure. The default delivers aborted and open data, so exactly-once output turns into at-least-once at the reader.
Single-broker transaction log in a test that becomes production
Test clusters often set the transaction log replication to one so a single broker can start. Copying that setting to production means a single broker failure can lose transaction state.
Auto commit left on
The consumer commits positions on a timer, unrelated to any transaction. A crash then leaves output and position out of step again.
Weak topic durability
With a single in-sync replica allowed, an acknowledged committed transaction can vanish when a broker dies, which undermines everything above.
Log the effective configuration of every producer and consumer at startup, and add a test to the deployment pipeline that fails if a transactional application starts with automatic commits on or with the default isolation level. Cheap checks catch expensive mistakes.
Three environments, three postures
The same application often runs in several environments, and the safe posture differs in each. A laptop or a continuous integration job wants speed of startup and tiny footprint, a staging cluster wants to behave like production so that failures are discovered early, and production wants maximum durability. Mixing these postures is a frequent source of surprises, so write them down explicitly and keep them in version control.
| Environment | Cluster size and replication | Transaction log settings | Purpose |
|---|---|---|---|
| Developer laptop | One broker, replication of one | Replication of one and minimum in-sync of one, so the single broker can start | Fast feedback, logic testing, never durability testing |
| Staging | Three brokers, replication of three | Same as production | Fault injection, rebalance drills, realistic timing |
| Production | Three or more brokers, replication of three | Replication of three and minimum in-sync of two | Real traffic with full durability |
The danger lies in the first row leaking into the third. A single-replica transaction log is convenient on a laptop but means that losing one broker in production could lose the state of every open transaction. Treat any environment file that mentions a transaction log replication of one as suspect, and require a code review before it can ever be applied to a shared cluster.
22Performance and Latency Trade-offs
Atomicity costs something. The cost is smaller than most people fear, and it is controllable.
Where the extra cost comes from
Coordinator round trips
Registering partitions, adding offsets, and ending the transaction each need a request to the coordinator. The count is per transaction, not per record.
Markers per partition
Each involved partition receives a marker at the end. A transaction that touches many partitions writes many markers.
Transaction log writes
Every state change is a replicated write to the transaction log, and the log must be healthy and fast.
Read latency for consumers
Committed-only readers wait for the marker, so end-to-end latency includes the transaction duration.
Amortizing with batch size
The fixed cost of a transaction is spread over all the records in it. A transaction that carries a single record pays the full coordination cost for one record. A transaction that carries a thousand records pays it once for all thousand. This is why the commit interval is the main tuning knob: a longer interval means larger transactions, higher throughput, and higher end-to-end latency, and a shorter interval means the reverse.
| Commit interval | Transactions per second | Throughput efficiency | Output visible to readers |
|---|---|---|---|
| Very short, tens of milliseconds | High | Lower, overhead dominates | Very quickly |
| Moderate, around a hundred milliseconds to a second | Moderate | Good balance | Quickly enough for most needs |
| Long, several seconds | Low | Highest | After a noticeable delay |
flowchart LR
A["Shorter commit interval"] --> B["Smaller transactions"]
B --> C["More coordinator work per record"]
B --> D["Lower latency for readers"]
E["Longer commit interval"] --> F["Larger transactions"]
F --> G["Less overhead per record"]
F --> H["Higher latency, more work redone after an abort"]
The cost of redoing work
Larger transactions carry another cost: after a failure, everything in the open transaction is redone. A ten-second transaction that fails after nine seconds wastes nine seconds of processing. Choose an interval that balances overhead against the acceptable amount of repeated work after a failure.
Partition fan-out
A transaction that writes to hundreds of partitions writes hundreds of markers and registers hundreds of partitions with the coordinator. If your pipeline fans out widely, consider whether every output really belongs in one transaction, or whether the output can be split into several loops with narrower fan-out.
In practice, well-tuned transactional pipelines reach a large fraction of the throughput of non-transactional ones. Benchmark your own workload with realistic batch sizes before deciding that the guarantee is too costly.
Sizing the coordinator side
The transaction coordinator role is spread across the brokers that lead partitions of the transaction log. Each transactional ID is assigned to one of those partitions by hashing its name, so the load of many producers spreads out on its own, provided there are enough transactional IDs and enough log partitions. A cluster with very few transactional IDs concentrates all coordination on a handful of brokers, while a cluster with a huge number of short-lived IDs fills the transaction log with state that must later expire.
Many long-lived transactional producers
This is the healthy shape. Load spreads across the log partitions, state per producer is small, and expiry is rarely needed.
A few very busy transactional producers
Also fine, as long as each transaction batches many records. The coordinators involved are busy but the total work is modest.
Producers created and discarded rapidly
The unhealthy shape. Every new transactional ID adds state to the transaction log and leaves expiring entries behind. Keep producers long-lived and reuse them across many transactions.
As a rule of thumb, watch the size and replication health of the transaction log topic, the number of active transactional IDs, and the request latency of the coordinator role. If any of them drifts steadily upward, investigate the producers before adding brokers.
23Security Considerations
Transactions add new resources that need access control, and new ways to interfere with other applications.
Permission on the transactional ID
A producer needs permission to use its transactional ID as a resource. Without it, any authorized client could claim another application’s identity, and fence it out.
Write permission on output topics
The usual per-topic write permission applies, and it covers the transactional writes as well.
Read permission on inputs and group permission
The consumer needs to read input topics and to use its consumer group, and the producer sends offsets on behalf of that group, so the same principal needs group access.
Protect the internal topics
The transaction log and the offsets topic are internal. Only brokers and administrators should have any direct access to them.
Encrypt and authenticate connections
Use TLS and strong authentication so that requests to the coordinator cannot be forged or read on the network.
Restrict administrative abort tools
Tools that list and abort transactions are powerful. Limit them to operators, and record their use.
If transactional IDs are guessable and unprotected, a misbehaving client can start a producer with another application’s ID, causing that application to be fenced repeatedly. Keeping transactional ID permissions tight, and naming IDs by application and purpose, prevents accidents and abuse alike.
Data visibility and privacy
Aborted records stay on disk until retention removes them. If a transaction wrote sensitive data and then aborted, the data still exists in the log for the retention period. Access to the topic is therefore access to the aborted data as well, and privacy or deletion requirements must account for it. Compacted or short-retention topics reduce the window.
24Monitoring, Logging, and Metrics
A healthy transactional pipeline is quiet. These signals tell you when it stops being healthy.
| Signal | What it tells you | Healthy pattern |
|---|---|---|
| Consumer lag of the input group | Whether the application keeps up | Small and steady |
| Gap between last stable offset and high watermark | Whether open transactions are delaying readers | Small, bounded by transaction length |
| Downstream consumer lag on transactional topics | End-to-end delay including waiting for markers | Stable, matches commit interval |
| Transaction commit and abort counts | Rate of successful and failed transactions | Aborts rare and tied to deployments or failures |
| Transaction commit time on the producer | How long the commit call takes | Low and stable |
| Fencing errors | Zombie instances or configuration mistakes | Near zero outside rebalances |
| Transaction timeouts on the coordinator | Producers that exceed their time budget | Zero or very rare |
| Transaction log partition health | Replication state of the internal topic | Fully in sync |
| Rebalance rate | Stability of the group | Rare, tied to planned changes |
Tools that ship with Kafka
Transactions tool
Lists transactional IDs, describes their state and open partitions, lists active producers on a partition, finds hanging transactions, and can abort them.
Log dump tool
Prints batches from partition segment files, showing the transactional flag, producer identity, and control records with their commit or abort type.
Consumer groups tool
Shows committed offsets and lag per partition, so you can compare the group position with the end of the log.
Console consumer with isolation setting
Lets you read a topic in both isolation modes and compare, which is a fast way to see the effect of aborted data.
Lag measured against the log end is misleading for transactional topics. Measure it against the last stable offset for committed-only readers, and alert when the distance between the last stable offset and the log end grows beyond what your longest transaction would explain.
flowchart TD
A["Alert: downstream lag rising"] --> B{"Is application input lag also rising?"}
B -->|"Yes"| C["Processing is too slow: scale out or tune the batch"]
B -->|"No"| D{"Is last stable offset stuck below high watermark?"}
D -->|"Yes"| E["Find the oldest open transaction, check its owner, abort if orphaned"]
D -->|"No"| F{"Are aborts and fencing errors increasing?"}
F -->|"Yes"| G["Check rebalances, timeouts, and deployment activity"]
F -->|"No"| H["Check downstream consumers and network"]
25Failure Scenarios and Recovery
Walk through the typical failures one at a time, and see what each leaves behind.
| Failure | State left behind | How it resolves |
|---|---|---|
| Application crashes mid-transaction | Hidden output, pending offsets, open transaction | Timeout or replacement instance aborts it, work is redone from the old offsets |
| Application crashes just after commit request | Coordinator may be in prepare-commit | Coordinator finishes writing markers, the commit stands, restart resumes after the committed offsets |
| Transaction coordinator broker fails | Transaction state in the replicated log | Another broker becomes coordinator, reads the log, and continues each transaction from its recorded state |
| Output partition leader fails mid-transaction | Transactional data replicated to followers | New leader continues, producer retries, deduplication by sequence numbers prevents repeats |
| Network partition isolates the application | Possibly a transaction that times out | Coordinator aborts on timeout, application is fenced or re-initializes, the work is redone |
| Rebalance moves partitions away | Open transaction on partitions no longer owned | Old owner is blocked from committing offsets, aborts, and the new owner reprocesses |
Crash after the commit request, before the reply
The application does not know whether the commit succeeded. On restart, it re-initializes, and the coordinator either finishes the commit or reports the transaction as already complete. The committed offsets tell the application where to resume, so no work is repeated, and none is skipped.
Coordinator failover during phase two
The new coordinator finds the transaction in prepare-commit, writes any missing markers, and marks it complete. Because markers are idempotent, repeating one is harmless.
Whole cluster restart
All state is in replicated logs, so after restart the coordinators reload it. Open transactions that were in progress are aborted by timeout or by the returning producers, and processing resumes from committed offsets.
flowchart TD
A["Failure occurs"] --> B{"Where in the transaction?"}
B -->|"Before commit requested"| C["Transaction will abort by timeout or takeover: output hidden, offsets unchanged"]
B -->|"After prepare-commit recorded"| D["Commit is certain: coordinator completes markers"]
B -->|"During marker writing"| E["New coordinator resumes: missing markers written, then complete"]
C --> F["Restart at old offsets, redo work"]
D --> G["Restart at new offsets, no repeat"]
E --> G
Every failure analysis reduces to one question: did the coordinator durably record the decision to commit? If yes, the outcome is committed no matter what crashes next. If no, the outcome is abort. There is no third state, and that is precisely what makes the guarantee dependable.
Slow-motion failures that look like something else
Not every problem announces itself with a crash. Some of the most confusing incidents in transactional pipelines are slow degradations whose symptoms appear far from their cause. Learning to recognize them saves hours.
Downstream lag with a healthy producer
Readers fall behind although the producing application looks fine and the topic is growing normally. The cause is often a single open transaction holding back the last stable offset. Compare the last stable offset with the log end before blaming the readers.
Repeated aborts after each deployment
Rolling restarts move partitions between instances, and each move aborts an in-flight transaction. A brief burst of aborts during a rollout is normal. A steady stream of aborts afterward suggests session timeouts that are too short for the batch size.
Fencing errors that never stop
Two instances are using the same transactional ID by mistake, perhaps because of a copied configuration file. Each fences the other in turn. The fix is a unique, stable ID per logical unit, not a retry loop around the error.
Output that looks doubled to one team only
One downstream team reads with the default isolation level and sees aborted records, while others read committed data and see none. The output topic is correct. The reader configuration is not.
State that seems to jump backward after a restart
The application rebuilt its local state from the change-log and appears to have lost recent updates. If those updates belonged to a transaction that aborted, this is correct behavior, and the work is redone. If not, check that the change-log is replicated and readable.
26Design Patterns and Anti-patterns
Some designs repeatedly work well. Others repeatedly fail in ways that only show up under load or failure.
Patterns worth copying
Keep the loop purely Kafka-to-Kafka
Restrict the transactional loop to reading and writing Kafka topics, and push external side effects into separate, idempotent stages. The core stays exactly once, and the messy parts are isolated.
One transactional unit per key range
Partition the input by key so that every business entity is handled by exactly one instance at a time. This avoids concurrent conflicting updates and makes state easy to reason about.
Dead letter topics inside the transaction
Route unprocessable records to a dead letter topic as part of the same transaction, so the loop advances past poison records and the bad record appears exactly once in the dead letter topic.
Idempotent and deduplicating consumers as a second layer
For data that matters, carry a business identifier in each event and let downstream systems deduplicate. The transaction handles Kafka-to-Kafka, and the identifier handles everything beyond it.
Short, bounded transactions
Cap batch size and processing time per transaction, so open transactions never hold back the last stable offset for long.
Anti-patterns to avoid
Problem
An application uses a transactional producer for its output but commits consumer offsets separately with a normal commit call after the transaction, or leaves automatic commits on.
Why It’s Harmful
The output and the offsets are in different, independent operations again. A crash between them produces duplicates or losses, and the transaction adds cost without adding the promised guarantee.
Correct Approach
Send the offsets through the producer, inside the transaction, together with the group metadata, and disable automatic commits.
Problem
The processing step calls an external service, such as a payment gateway, in the middle of the transaction and assumes an abort will undo it.
Why It’s Harmful
An abort cannot reach outside Kafka. On retry, the call is made again, and the customer is charged twice.
Correct Approach
Write a command event with an identifier, and let a dedicated service perform the call using the identifier as an idempotency key.
Problem
A team generates a random transactional ID at every startup, or shares one ID across all application instances.
Why It’s Harmful
A random ID never fences its predecessors, leaving orphaned transactions to time out and delay readers. A shared ID makes instances fence each other constantly.
Correct Approach
Derive the ID from a stable identity of the unit of work, and ensure two live instances never use the same one unless one is meant to replace the other.
Problem
Every record is wrapped in its own tiny transaction to achieve the lowest possible latency.
Why It’s Harmful
Coordinator round trips and markers are paid per record. Throughput collapses, the transaction log gets hammered, and brokers spend more time on bookkeeping than on data.
Correct Approach
Batch many records per transaction, and tune the commit interval to the latency you actually need.
Problem
Transactional topics are read by tools and services left on the default isolation level.
Why It’s Harmful
Those readers process aborted and unfinished data. Reports and side effects then reflect work that never officially happened.
Correct Approach
Standardize client configuration templates so every reader of transactional topics uses committed-only isolation, and add automated checks.
27Best Practices and Common Mistakes
A checklist for design reviews, code reviews, and production readiness reviews.
Best practices
- Send offsets through the producer, with group metadata, inside every transaction
- Disable automatic commits and set committed-only isolation on all readers
- Use stable, well-named transactional IDs with tight access control
- Keep transactions short, and size batches to balance throughput and latency
- Use three replicas and two in-sync replicas for topics and for the transaction log
- Abort, rewind, and retry on errors, and treat fencing as fatal
- Prefer a stream processing library over hand-written loops where possible
- Make every external effect idempotent, with keys derived from input records
Common mistakes
- Calling the setup “exactly once” and skipping downstream deduplication
- Forgetting to rewind the consumer after an abort
- Reading transactional output with default isolation
- Long processing steps that exceed the transaction timeout
- Ignoring the stuck last stable offset as a cause of downstream lag
- Assuming offsets are dense and treating gaps as data loss
- Copying single-broker test settings into production
- Treating retries around fencing errors as normal
A production readiness review
Draw the boundary
List every place the loop reads from and writes to. Mark anything outside Kafka, and decide how each is made idempotent.
Check the configuration
Verify transactional ID, offsets sent through the producer, automatic commits off, isolation set for every reader, and durability settings on topics and the transaction log.
Rehearse failures
Kill instances mid-transaction, restart brokers, force rebalances, and count the output records against the input.
Prepare the runbook
Document how to find and abort hanging transactions, how to read lag against the last stable offset, and who may run administrative tools.
Monitor continuously
Track aborts, fencing errors, commit times, rebalance rate, and the gap between the last stable offset and the log end.
Feed a known number of unique input records, inject failures repeatedly, and then read the output with committed-only isolation. The output must contain exactly one result for each input, in the right order per key. Run this test in the deployment pipeline for every change to the loop.
28Real-World and Industry Examples
The same loop appears in very different industries, each with its own reasons to demand correctness.
Payments and banking
A pipeline reads payment requests, checks them against rules, and writes approved and declined events to separate topics. A duplicate approval would double the impact on a ledger, and a lost decision would leave a customer waiting. The transactional loop keeps decisions and progress together, while the actual money movement uses idempotency keys with the external system.
E-commerce order processing
Orders flow through validation, inventory reservation, and fulfillment topics. Each stage reads events and writes the next stage’s events. Atomic stages mean that an order is never both reserved and unreserved in the visible stream, and a retry after a crash does not reserve stock twice.
Fraud scoring
A scoring service reads transactions, enriches them with recent activity from state kept in Kafka, and writes scores. Stateful exactly-once processing means the running counters behind the score reflect each event once, so scores do not drift after failures.
Telecom usage aggregation
Streams of usage records are aggregated into billing totals. A repeated record would overcharge a customer. Transactional aggregation, with changelog-backed state, keeps totals exact even across restarts.
Logistics and ride tracking
State changes for trips and shipments are transformed and joined with reference data. Committed-only readers see consistent snapshots of transitions, never half an update spread across topics.
Data platform pipelines
Central data teams reshape raw events into cleaned, conformed topics for many consumers. Exactly-once between topics ensures every downstream team starts from the same trusted facts, without each one building its own deduplication.
| Need | Best fit | Why |
|---|---|---|
| Kafka-to-Kafka transformation with strict correctness | Transactional read-process-write | Atomic outputs and offsets |
| Stateful aggregation and joins | Stream processing library in exactly-once mode | State changes join the transaction through change-log topics |
| Kafka to database with strong correctness | Idempotent upserts or offsets stored with results | Transaction boundary ends at Kafka |
| Publish events when a service changes its database | Transactional outbox | One local database transaction, reliable relay afterwards |
| Low-value telemetry | At-least-once with idempotent producer | Simplest and fastest, duplicates are cheap |
29Frequently Asked Questions
You can, and for many workloads that is the right choice. It gives at-least-once behavior. If duplicates are expensive, as with money or counters, a transaction removes them at moderate cost.
No. Transactions in Kafka control the visibility of writes. They do not lock input records or prevent other applications from reading them. Business conflicts are avoided by giving each key a single processing owner.
They stay in the log until retention deletes them, but committed-only readers skip them, using the abort marker and the broker’s index of aborted ranges.
Control records for commit and abort markers occupy offsets, and aborted data is skipped. Both make the delivered offsets non-consecutive, which is normal.
No. The high watermark reflects replication, while the last stable offset also accounts for open transactions. It is never higher than the high watermark, and equals it when nothing is open.
For committed-only readers, yes. Readers cannot pass the earliest open transaction on a partition, so records written after it by other producers wait too.
Not with modern clients and brokers. Fencing is based on the consumer group generation, so one producer per thread is enough. Older designs needed one producer per input partition.
Treat the call as outside the guarantee. Use an idempotency key so repeats are recognized, or emit a command event and let a dedicated service perform the call.
Somewhat, mostly through added latency and coordination work per transaction. With sensible batch sizes the throughput cost is usually modest, but you should measure your own workload.
Catch the error for that record and write it to a dead letter topic inside the same transaction, so the loop can move past it. Otherwise abort-and-retry loops forever on the same record.
Another broker takes over, reads the replicated transaction log, and finishes the job from the recorded state. A recorded commit decision is never reversed.
Not when configured correctly. It is a replicated internal topic. Use three replicas and a minimum of two in-sync replicas so the loss of one broker does not stop transactions or lose state.
The standard pattern relies on group offsets and group metadata. For consumers that manage positions themselves, the safe alternative is to store the position together with results in the target system.
It delays data until transactions complete. Consumers that can tolerate aborted or duplicate data and want the lowest latency may choose the default, but they give up the guarantee.
Yes. Change-log topics used for state are compacted and transactional at the same time. Aborted records are ignored by readers, and compaction removes old values in the background.
Run a fault-injection test: send uniquely numbered inputs, kill and restart instances and brokers during processing, then read the output with committed-only isolation and confirm each input appears once.
A batch. The fixed cost of coordination is spread across all records in the transaction, so larger batches raise throughput. Choose the batch so the added latency and the amount of work redone after an abort are both acceptable.
It reads them to learn where transactions end, uses them to decide which data to deliver or skip, and then discards them. They never reach the application, but they do take up offsets.
Yes, but readers of the output must handle both. Committed-only readers deliver ordinary non-transactional data as soon as it is stable, so they work with a mixed topic. Plan the switch carefully, and check that no reader still uses default isolation.
Any open transaction stays open until its timeout expires, and then the coordinator aborts it. During that time it can delay committed-only readers. Shut applications down gracefully, so they can commit or abort before exiting.
Adding partitions changes how keys map to partitions, which can break per-key ordering and state assumptions for stateful loops. Plan such changes carefully, ideally with a drain and restart, and test them in staging.
So the group coordinator can verify that the sender is still a current member of the group at the right generation. A stale member is rejected, and that check is what fences zombies without needing one producer per partition.
Per output partition, records appear in the order they were written, which follows the processing order for a single instance. Across different output partitions there is no ordering guarantee, as is true for Kafka in general.
Fencing errors, meaning a newer holder of the identity exists, are fatal for that instance and it should stop. Temporary abortable errors allow an abort, a rewind, and a retry with the same producer. Check the documentation of your client version for the precise list.
30Summary and Key Takeaways
A read-process-write loop touches an input log, an output log, and an offsets log, and a crash between any two touches breeds either duplicates or losses. No ordering of the steps can fix this. Kafka’s answer is to make the output records and the consumed offsets a single transaction, coordinated by a broker that records its decision durably before telling any partition, and to let committed-only readers respect the outcome. Fencing through epochs and consumer group generations stops zombie instances, timeouts and tooling deal with stuck transactions, and stream processing libraries wrap the whole pattern, including state, into one setting. The promise is precise and bounded: exactly-once effects inside Kafka, and idempotent designs at every boundary outside it.
Key Takeaways
- The gap is the problem — writing output and committing offsets are two operations, and a crash between them causes duplicates or losses whichever comes first.
- Make them one — sending offsets through the transactional producer turns output and progress into one atomic unit that commits or aborts together.
- The decision is durable first — the coordinator records commit or abort in a replicated log before writing markers, so any crash can be resumed to the same outcome.
- Readers complete the guarantee — committed-only isolation hides aborted and open data, and the last stable offset can delay readers while a transaction is open.
- Fencing stops zombies — stable transactional IDs, epochs, and group generations block old instances from writing or committing after a takeover.
- The boundary is Kafka — databases and external services need idempotent writes, stored offsets, an outbox, or command events, because aborts cannot undo outside effects.
- Operate it deliberately — keep transactions short, verify configuration, monitor aborts and the last stable offset, know the tools for hanging transactions, and prove behavior with fault-injection tests.