Read-Process-Write Pattern Guarantees in Kafka

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”.

Simple Analogy

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
        
FIG 1 — The loop: read, process, write output, and record progress

Why this pattern is everywhere

Use

Enrichment

Read raw events, add customer or product details, and write enriched events for other teams to use.

Use

Filtering and routing

Read a mixed stream and write each kind of event to its own topic, dropping the ones nobody needs.

Use

Aggregation

Read individual events and write running totals, counts, or windows of statistics.

Use

Format conversion

Read data in one shape and write it in another, for example turning raw logs into structured records.

Use

Workflow steps

Read a command, perform a business step, and write an event that triggers the next step in a chain of microservices.

Use

Stream processing frameworks

Libraries such as Kafka Streams are essentially large, well-organized read-process-write loops with state.

i
Key Insight

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
        
FIG 2 — A crash between the write and the commit produces duplicates
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
        
FIG 3 — A crash between the commit and the write causes data loss
Order of stepsCrash in the gap causesName
Commit offsets, then write outputLost outputAt most once
Write output, then commit offsetsRepeated outputAt least once
Write output and commit offsets as one atomic unitNeither, the whole unit is redone or keptExactly once, within Kafka
!
Common Misconception

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.

Term 1

Consumer offset

A bookmark that records how far a consumer group has read in each partition. Stored in an internal topic.

Term 2

Transaction

A group of writes, to several partitions, plus offset commits, that are made visible together or discarded together.

Term 3

Transactional ID

A name chosen by the application that gives a producer a stable identity across restarts, so old instances can be fenced.

Term 4

Transaction coordinator

A broker role that tracks the state of each transaction and drives it to completion.

Term 5

Transaction log

An internal replicated topic in which the coordinator records every transaction state change.

Term 6

Commit and abort markers

Special control records written into each involved partition to declare a transaction committed or aborted.

Term 7

Isolation level

A consumer setting that decides whether it sees records of unfinished or aborted transactions or only committed ones.

Term 8

Last stable offset

The position below which every transaction has finished. Committed-only readers never read beyond it.

Term 9

Zombie

An old instance of an application that still runs after a newer instance has taken over its work.

Term 10

Fencing

Rejecting requests from a zombie, using epochs and group generations, so it cannot corrupt the data.

Term 11

Exactly-once semantics

The result of a read-process-write loop looks as if every input record was processed one time, even after failures.

Term 12

Idempotent producer

A producer feature that removes duplicates caused by its own retries, and the base layer under transactions.

i
Naming Note

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.

Simple Analogy

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"]
        
FIG 4 — Output and offsets share one fate

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.

“You cannot make two steps safe by ordering them cleverly. You make them safe by making them one step.”

What has to be true for this to work

1

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.

2

Offsets can join the group

The consumed offsets must be committable as part of the same transaction, not through a separate side channel.

3

Readers respect the outcome

Downstream consumers must be able to ignore the writes of transactions that were aborted or are still open.

4

Zombies are stopped

An old instance must be prevented from writing into the same work after a new one has taken over.

5

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
        
FIG 5 — The parts of Kafka that cooperate in a transactional read-process-write

The roles, one by one

1

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.

2

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.

3

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.

4

The transaction log

An internal, replicated topic where the coordinator records each state change, so the outcome survives coordinator failures.

5

The partition leaders

Store the transactional records and later the markers, and they help readers know which records are safe to deliver.

6

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.

!
Common Mistake

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.

Simple Analogy

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
        
FIG 6 — A newer epoch blocks an older instance

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.

ChoiceEffectVerdict
Stable name tied to the unit of workRestarts reuse identity, old instances are fencedCorrect
Random name per startNo fencing, orphaned open transactions linger until they time outWrong
Same name across different applicationsApplications fence each other in confusing waysWrong

7The Transaction Lifecycle, Step by Step

Follow one complete transaction from the first poll to the final marker.

1

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.

2

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.

3

Begin the transaction

The producer marks the start locally. The coordinator learns about the transaction lazily, when the first partition is added.

4

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.

5

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.

6

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.

7

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
        
FIG 7 — One transaction from first record to committed marker
i
Why “lazily”?

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.

PhaseWho waitsWhat dominates the delay
Poll and processThe applicationBatch size and the cost of the processing logic
Register partitions and offsetsThe producer, brieflyNetwork round trips to the coordinator, once per new partition
Commit requestThe producerReplicated write of the commit decision to the transaction log
Marker writingThe coordinator, in the backgroundNumber of involved partitions and their leaders’ responsiveness
Visibility for readersDownstream consumersTime 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.

Simple Analogy

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"]
        
FIG 8 — Pending offsets follow the fate of the transaction

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 generationFencing based onProducers needed
Original designTransactional ID tied to each input partitionOne producer per input partition, many producers per application
Improved designConsumer group generation and member informationOne producer per application thread
!
Common Mistake

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

State

Empty

The producer has no open transaction. This is the resting state between transactions.

State

Ongoing

Partitions and offsets are being added. The transaction clock is ticking against its timeout.

State

Prepare commit

The producer asked to commit. The coordinator has durably recorded the decision, and can no longer change its mind.

State

Prepare abort

Either the producer asked to abort or the transaction timed out. The abort decision is durably recorded.

State

Complete commit

All commit markers have been written to every involved partition. The transaction is finished.

State

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
        
FIG 9 — The state machine kept by the transaction coordinator

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.

Simple Analogy

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.

i
Durable decision first

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"]
        
FIG 10 — A partition log with a transaction from one producer interleaved with other data
Item in the logVisible to a committed-only reader?Notes
Normal, non-transactional batchYes, once below the stable pointNot affected by transactions
Transactional batch with commit markerYes, after the marker is writtenRecords appear in offset order
Transactional batch with abort markerNo, filtered outThe data stays on disk until retention removes it
Transactional batch, no marker yetNo, held backBlocks the stable point, so later records also wait
Control recordNever delivered to the applicationConsumes an offset, creating visible gaps
!
Offset gaps are normal

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
!
A frequent trap

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.

Simple Analogy

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"]
        
FIG 11 — How a committed-only consumer filters what it reads

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.

QuestionAnswer 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
        
FIG 12 — Interleaved transactions and how a reader waits for the earliest one
i
Practical meaning

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"]
        
FIG 13 — Abort, rewind, retry

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.

!
Fatal versus recoverable

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

1

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.

2

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.

3

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
        
FIG 14 — The group generation blocks the old owner from committing

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.

i
Frameworks handle this for you

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 choiceIf too shortIf too long
Transaction timeoutHealthy but slow batches get aborted and retried, wasting work and adding latencyA crashed producer blocks committed-only readers until the timeout passes
Session and poll timeouts of the consumer groupSlow batches trigger unnecessary rebalancesDead 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"]
        
FIG 15 — Diagnosing a stuck last stable offset
i
Tooling exists

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.

!
Abort with care

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.

Promise 1

Atomicity

Outputs to multiple partitions and topics, together with offsets, are all visible or none are visible.

Promise 2

No loss

An input record whose offset was committed has its output committed. Uncommitted input is redone.

Promise 3

No visible duplicates

Retries, restarts, and zombies cannot produce repeated committed outputs for the same input.

Promise 4

Durability

A committed transaction survives broker failures, given a proper replication setup with strong acknowledgements.

What the promise depends on

1

Correct configuration

Transactional ID set, automatic offset commit off, committed-only readers, strong replication and acknowledgement settings.

2

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.

3

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.

4

Downstream respects isolation

The readers of the output must use committed-only isolation, or the atomic visibility is lost.

“Exactly once is not a property of a message. It is a property of an outcome, inside a boundary.”

Common misreadings of the guarantee

Myth 1

“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.

Myth 2

“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.

Myth 3

“Nothing is ever written twice to the log”

Aborted attempts leave real records on disk. They are hidden from committed-only readers, not erased.

Myth 4

“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.

LIMIT-01 Boundary
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.

LIMIT-02 Boundary
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.

LIMIT-03 Boundary
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.

LIMIT-04 Boundary
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.

i
One-line summary

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"]
        
FIG 16 — The transactional outbox pattern

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.

PatternBest forMain requirement
Idempotent sinkDatabases and indexes with unique keysStable identifier per record
Offset stored with resultTransactional databases as the final targetSeek logic at startup and rebalance
Transactional outboxServices that update a database and must publish eventsA reliable relay and consumer deduplication
Command eventsEmails, payments, calls to third partiesIdempotency keys supported by the external system
!
Not every external system is friendly

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"]
        
FIG 17 — State changes travel inside the same transaction

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.

OptionProducersGuaranteeCommit interval default
At least onceOrdinary idempotent producerNo loss, possible duplicatesLong, tens of seconds
Exactly once, originalOne transactional producer per taskAtomic, but heavier on resourcesShort, fractions of a second
Exactly once, version twoOne transactional producer per threadAtomic, lighter and more scalableShort, fractions of a second
i
Why the commit interval shortens

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.

QuestionHand-written loopStream processing library
Control over each stepCompleteGuided by the library’s model
Rebalance and abort handlingYou write and test itBuilt in and widely exercised
State managementYou design change-log topics and recoveryProvided, with change-log topics and restore logic
Effort to reach exactly-onceHigh, and easy to get subtly wrongA single configuration switch
Best forSmall, stateless transformations with special needsStateful processing, joins, windows, and larger pipelines
i
A sensible default

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 crashWhereVisible to committed-only readers?
Five enriched records, offsets 700 to 704Output partitionNo, no marker yet, they sit beyond the last stable offset
Pending offset 205Offsets topicNo, still pending, group position remains 200
Open transactionTransaction logOngoing 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
        
FIG 18 — A crash, an abort, and a clean redo of the same five records

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.

!
Lesson

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.

ComponentSettingRequired value or guidance
Producertransactional.idStable, unique per logical unit of work, never random
Producerenable.idempotenceTrue, and implied by a transactional ID
ProduceracksAll in-sync replicas must acknowledge
Producertransaction.timeout.msLonger than the slowest healthy batch, and within the broker maximum
Consumerenable.auto.commitFalse, the producer commits offsets inside the transaction
Consumerisolation.levelRead committed, for the input and for every downstream reader
Consumermax.poll.interval.ms and session timeoutComfortably longer than one batch, so slow batches do not cause rebalances
Brokertransaction.state.log.replication.factorThree in production, so the transaction log survives broker loss
Brokertransaction.state.log.min.isrTwo, so transaction state changes require a majority of replicas
Brokertransaction.max.timeout.msUpper bound on what producers may ask for
TopicsReplication factor and min.insync.replicasThree and two, so committed data survives a failover
ClusterUnclean leader electionDisabled, 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.

i
Verify, do not assume

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.

EnvironmentCluster size and replicationTransaction log settingsPurpose
Developer laptopOne broker, replication of oneReplication of one and minimum in-sync of one, so the single broker can startFast feedback, logic testing, never durability testing
StagingThree brokers, replication of threeSame as productionFault injection, rebalance drills, realistic timing
ProductionThree or more brokers, replication of threeReplication of three and minimum in-sync of twoReal 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

Cost

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.

Cost

Markers per partition

Each involved partition receives a marker at the end. A transaction that touches many partitions writes many markers.

Cost

Transaction log writes

Every state change is a replicated write to the transaction log, and the log must be healthy and fast.

Cost

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 intervalTransactions per secondThroughput efficiencyOutput visible to readers
Very short, tens of millisecondsHighLower, overhead dominatesVery quickly
Moderate, around a hundred milliseconds to a secondModerateGood balanceQuickly enough for most needs
Long, several secondsLowHighestAfter 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"]
        
FIG 19 — The commit interval balances throughput against latency

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.

i
Measure before worrying

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.

Control 1

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.

Control 2

Write permission on output topics

The usual per-topic write permission applies, and it covers the transactional writes as well.

Control 3

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.

Control 4

Protect the internal topics

The transaction log and the offsets topic are internal. Only brokers and administrators should have any direct access to them.

Control 5

Encrypt and authenticate connections

Use TLS and strong authentication so that requests to the coordinator cannot be forged or read on the network.

Control 6

Restrict administrative abort tools

Tools that list and abort transactions are powerful. Limit them to operators, and record their use.

!
Denial of service by identity theft

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.

SignalWhat it tells youHealthy pattern
Consumer lag of the input groupWhether the application keeps upSmall and steady
Gap between last stable offset and high watermarkWhether open transactions are delaying readersSmall, bounded by transaction length
Downstream consumer lag on transactional topicsEnd-to-end delay including waiting for markersStable, matches commit interval
Transaction commit and abort countsRate of successful and failed transactionsAborts rare and tied to deployments or failures
Transaction commit time on the producerHow long the commit call takesLow and stable
Fencing errorsZombie instances or configuration mistakesNear zero outside rebalances
Transaction timeouts on the coordinatorProducers that exceed their time budgetZero or very rare
Transaction log partition healthReplication state of the internal topicFully in sync
Rebalance rateStability of the groupRare, tied to planned changes

Tools that ship with Kafka

Tool

Transactions tool

Lists transactional IDs, describes their state and open partitions, lists active producers on a partition, finds hanging transactions, and can abort them.

Tool

Log dump tool

Prints batches from partition segment files, showing the transactional flag, producer identity, and control records with their commit or abort type.

Tool

Consumer groups tool

Shows committed offsets and lag per partition, so you can compare the group position with the end of the log.

Tool

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.

i
Alert on the right lag

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"]
        
FIG 20 — A triage path for slowness in a transactional pipeline

25Failure Scenarios and Recovery

Walk through the typical failures one at a time, and see what each leaves behind.

FailureState left behindHow it resolves
Application crashes mid-transactionHidden output, pending offsets, open transactionTimeout or replacement instance aborts it, work is redone from the old offsets
Application crashes just after commit requestCoordinator may be in prepare-commitCoordinator finishes writing markers, the commit stands, restart resumes after the committed offsets
Transaction coordinator broker failsTransaction state in the replicated logAnother broker becomes coordinator, reads the log, and continues each transaction from its recorded state
Output partition leader fails mid-transactionTransactional data replicated to followersNew leader continues, producer retries, deduplication by sequence numbers prevents repeats
Network partition isolates the applicationPossibly a transaction that times outCoordinator aborts on timeout, application is fenced or re-initializes, the work is redone
Rebalance moves partitions awayOpen transaction on partitions no longer ownedOld 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
        
FIG 21 — The moment of failure decides which side of the commit point you land on
i
The commit point

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

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

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

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

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

ANTI-PATTERN-05 Avoid
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

1

Draw the boundary

List every place the loop reads from and writes to. Mark anything outside Kafka, and decide how each is made idempotent.

2

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.

3

Rehearse failures

Kill instances mid-transaction, restart brokers, force rebalances, and count the output records against the input.

4

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.

5

Monitor continuously

Track aborts, fencing errors, commit times, rebalance rate, and the gap between the last stable offset and the log end.

i
A simple acceptance test

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.

NeedBest fitWhy
Kafka-to-Kafka transformation with strict correctnessTransactional read-process-writeAtomic outputs and offsets
Stateful aggregation and joinsStream processing library in exactly-once modeState changes join the transaction through change-log topics
Kafka to database with strong correctnessIdempotent upserts or offsets stored with resultsTransaction boundary ends at Kafka
Publish events when a service changes its databaseTransactional outboxOne local database transaction, reliable relay afterwards
Low-value telemetryAt-least-once with idempotent producerSimplest and fastest, duplicates are cheap

29Frequently Asked Questions

Q1Why can’t I just commit the offset after writing and accept the rare duplicate?

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.

Q2Do transactions lock the records I read?

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.

Q3What happens to records written by an aborted transaction?

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.

Q4Why are there gaps in the offsets I receive?

Control records for commit and abort markers occupy offsets, and aborted data is skipped. Both make the delivered offsets non-consecutive, which is normal.

Q5Is the last stable offset the same as the high watermark?

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.

Q6Can one slow transaction block other producers’ data?

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.

Q7Do I need a separate producer for each input partition?

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.

Q8What if my code needs to call a web service?

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.

Q9Does exactly-once processing make my pipeline slower?

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.

Q10How do I handle a record that always fails?

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.

Q11What happens if the coordinator crashes during a commit?

Another broker takes over, reads the replicated transaction log, and finishes the job from the recorded state. A recorded commit decision is never reversed.

Q12Is the transaction log a single point of failure?

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.

Q13Can I use transactions with a consumer that does not use groups?

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.

Q14Does read committed hide data from consumers that only need speed?

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.

Q15Are compacted topics compatible with transactions?

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.

Q16How can I prove my pipeline is exactly once?

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.

Q17Should the transaction wrap a single record or a batch?

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.

Q18What does the consumer do with commit and abort markers?

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.

Q19Can I run the same application with and without transactions during a migration?

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.

Q20What happens to a transaction if the application is scaled to zero?

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.

Q21Is it safe to change the number of input partitions?

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.

Q22Why does the producer need the consumer’s group metadata?

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.

Q23Do transactions guarantee that outputs appear in the order the inputs were read?

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.

Q24Which errors should make me shut the producer down?

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.