Kafka transactions and "exactly once": output topic and offsets committed together Advanced
Estimated reading time: 5 min read
Read first
The problem
Section titled “The problem”A processor reads OrderPaid at offset 7 and writes InvoiceCreated to another topic. It publishes, then dies before committing the input offset: the new consumer rereads offset 7 and publishes the invoice a second time. Reverse the order and a crash in between drops the invoice (offset commit article).
When both the input offset and the output live in Kafka, a transaction combines them into one commit boundary. The scope of “exactly once” is: read Kafka, process, write Kafka (plus the input offset). Writing PostgreSQL, calling a payment API or sending email is outside it.
Mechanism
Section titled “Mechanism”offset 7The processor reads the record at offset 7; the group's committed position is still 7.
- A stable
transactional.idlets Kafka identify the session and fence (block) the old producer when a new instance replaces it. - Within the transaction: produce the output, send the input offset (the next read position, i.e. 8 after record 7) into the transaction, then commit.
- Commit: output and offset are confirmed together. Abort: the output is treated as aborted, the offset does not advance, and the input is read again.
- The idempotent producer is the foundation; transactions add atomicity across a group of writes and the offset.
- The consumer reading the input must set
enable.auto.commit=false; after an abort, the application must reset its read position (an abort does not move the in-memory cursor back to the old offset).
| Situation | read_committed sees the output? |
Input offset advances? |
|---|---|---|
| Abort | No | No |
| Commit | Yes | Yes, in the same transaction |
| PostgreSQL written, then Kafka aborts | No | The DB is already written: systems diverge |
LSO: an open transaction blocks the whole partition
Section titled “LSO: an open transaction blocks the whole partition”A read_committed consumer only reads up to the LSO (last stable offset: the first offset of a still-open transaction, or the end of the log if none). Every record after the LSO is held back, including regular committed records from other producers.
Producer A opens a transaction and writes a record at offset 10. The transaction has not ended.
Operational consequence: a long-open transaction increases read latency for the whole partition. The broker aborts transactions exceeding transaction.timeout.ms on its own, so keep it short enough.
Config cheat sheet
Section titled “Config cheat sheet”| Config | Default | Notes |
|---|---|---|
transactional.id (producer) |
null | Without it, transactions cannot be used |
transaction.timeout.ms (producer) |
60000 | Exceeding the broker’s transaction.max.timeout.ms is rejected |
transaction.max.timeout.ms (broker) |
900000 (15 minutes) | Upper bound for the timeout |
isolation.level (consumer) |
read_uncommitted |
Downstream consumers must set read_committed |
enable.auto.commit (input consumer) |
true |
Set false in a transactional processor |
transaction.state.log.replication.factor (broker) |
3 | A single-broker dev cluster must lower it to 1 |
transaction.state.log.min.isr (broker) |
2 | Same as above; do not copy dev config to production |
Since Kafka 4.0, the stronger transaction protocol (KIP-890) is enabled when the upgrade is finalized: the producer epoch increases with each transaction. The upgrade docs advise applications to treat both TimeoutException and TransactionAbortableException as reasons to abort the transaction.
Practical recommendations
Section titled “Practical recommendations”- Use Kafka Streams with
processing.guarantee=exactly_once_v2instead of hand-writing the consume-transform-produce loop: the library handles fencing, rebalances and position resets. - Hand-write it with the Java consumer/producer only if you accept managing group metadata, epochs and commit/abort errors.
- When the processor also writes PostgreSQL: use idempotent writes keyed on
eventId, or an outbox and inbox; a Kafka transaction cannot lock the DB.
When to use / not use
Section titled “When to use / not use”| Use | Do not use |
|---|---|
| Kafka-to-Kafka pipelines (enrich, join, aggregate) | The final destination is a DB or an external API |
| Need atomic output across multiple partitions/topics | Only need to prevent retry duplicates (the idempotent producer is enough) |
| Using Kafka Streams | The team cannot yet operate at-least-once plus an idempotent consumer |
Real-world example
Section titled “Real-world example”An in-memory state model (an array and a number; it creates no real transaction markers or fencing):
npx --prefix examples ts-node examples/src/kafka-transactions/transaction-model.tsOutput: on the aborted attempt, read_uncommitted sees the event, read_committed does not, and the input offset stays at 7; after the successful attempt, read_committed sees one invoice-created and the input offset is 8. The repo only has a single-node Kafka 3.6 broker, so real transactions have not been run.
Trade-offs
Section titled “Trade-offs”The Kafka-to-Kafka boundary
- Output and input offset commit or abort together.
read_committedhides the output of aborted transactions.- Fencing stops the old producer from writing after a new instance replaces it.
Operational limits
- More complex than at-least-once: aborts, position resets, rebalances, transaction duration.
- Long-open transactions increase read latency via the LSO.
- PostgreSQL, email and HTTP are outside the transaction.
Production checklist
Section titled “Production checklist”- Use only for Kafka-to-Kafka flows; use idempotency everywhere else.
- A stable
transactional.id, unique per instance/partition;enable.auto.commit=falsefor the input. - Downstream consumers set
isolation.level=read_committed. - Keep
transaction.timeout.msshort enough; monitor LSO-induced latency. - Production:
transaction.state.logRF=3, min ISR=2. - Prefer Kafka Streams
exactly_once_v2. - Test a crash between produce and commit, restarts and rebalances.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) The output was produced but the transaction aborted: what does read_committed see? (2) Why must auto-commit of the input offset be disabled? (3) How does an open (uncommitted) transaction affect read_committed consumers of that partition?