Kafka idempotent producer: retrying the same batch does not duplicate, a new send still can Intermediate
Estimated reading time: 4 min read
Read first
The problem
Section titled “The problem”The order service sends OrderPaid. The broker has appended the record but the response is lost because the connection dropped; the producer only sees a timeout and does not know whether the first request succeeded.
Without a retry the event may be lost; with a blind retry it may be written twice, and billing books the entry twice.
The idempotent producer solves exactly this uncertainty at the producer-to-Kafka boundary: the broker recognizes a retry of the same batch and does not append it a second time. A new send call for the same event is not protected.
Mechanism
Section titled “Mechanism”The broker identifies a producer by its producer ID (PID) and a per-partition sequence number. A retry keeps the PID and sequence of the original batch.
event-42The app sends an event; the producer attaches a producer ID and sequence number to the batch for the partition.
| Send | PID / sequence | Broker has seen it? | Result |
|---|---|---|---|
| First batch | 7 / 0 | No | Append |
| Retry of the same batch after a lost ack | 7 / 0 | Yes | No second append |
App issues a new send with the same eventId |
7 / 1 | No | Appends a second record |
The broker does not understand OrderPaid or orderId; it only compares PID and sequence. Two independent send calls with the same payload are still two valid records.
Config cheat sheet (Java producer 4.2)
Section titled “Config cheat sheet (Java producer 4.2)”| Config | Default | Notes |
|---|---|---|
enable.idempotence |
true (default since 3.0) |
If set explicitly with a conflicting config, a ConfigException is thrown; if relying on the default with a conflict, idempotence is silently disabled |
acks |
all |
Idempotence requires all |
retries |
2147483647 | The practical limit is delivery.timeout.ms |
delivery.timeout.ms |
120000 | Total time for one send, including retries |
max.in.flight.requests.per.connection |
5 | Must be <= 5 with idempotence on |
linger.ms |
5 (changed from 0 in 4.0) | Wait time to fill a batch |
transactional.id |
null | Only needed when using transactions |
Check the effective config after merging defaults, framework settings and environment variables: an acks=1 from the environment can silently disable idempotence.
Scope and limits
Section titled “Scope and limits”- Ordering: with idempotence and
max.in.flight<=5, retries do not reorder batches within a partition. The producer config docs only state the<= 5requirement; the reason lies in the broker implementation (it keeps sequence state for only a few recent batches per producer/partition pair), not a documented contract. - PID lifecycle: a restarted producer gets a new PID, so a
sendafter the restart is not treated as a retry of the earlier batch. - Scope: one producer session and one partition. It does not span multiple partitions, multiple sessions, or the DB.
- Does not replace
acks=all: idempotence prevents duplicates on retry; durability is the job of acks, ISR and min.insync.replicas (ISR article).
A scenario right at the boundary: the DB records the order as paid but does not store that the event was published. After a restart, the application calls send again. This is a new batch, and the topic has two OrderPaid.
The fix: a transactional outbox on the publishing side and an idempotent consumer on the receiving side.
When to use / not use
Section titled “When to use / not use”- Always on (already the default in 4.x); there is no reason to turn it off for a regular producer.
- Not enough when you need atomic writes across partitions or tied to input offsets: use Kafka transactions.
- Does not replace a business event ID and consumer-side dedupe.
Real-world example
Section titled “Real-world example”A model of PID/sequence state using a Map (not a real producer or broker; not for verifying config). The repo has a KafkaJS 2.2 demo running against a Kafka 3.6 broker; this article describes config per 4.2.
npx --prefix examples ts-node examples/src/kafka-idempotent-producer/retry-model.tsOutput: first send: appended, same batch after lost ack: duplicate retry, new logical send: appended; the simulated log ends with two order-42-paid.
Trade-offs
Section titled “Trade-offs”Producer retry protection
- Retrying the same batch after a lost ack does not append a copy.
- Keeps batch order within a partition when configured correctly.
- The application does not have to guess write state from a timeout.
Limits
- Conflicting config can disable idempotence or make the producer fail.
- Does not dedupe a new send with the same payload or event ID.
- Does not make a DB-to-Kafka pipeline exactly-once.
Production checklist
Section titled “Production checklist”- Keep
enable.idempotence=true,acks=all,max.in.flight<=5; log the effective config at startup. - Important topics use RF=3,
min.insync.replicas=2. - Create the
eventIdonce at the business moment, store it durably, and reuse it for every publish. - Use an outbox when the DB and Kafka must stay consistent.
- Consumers dedupe by
eventId. - Handle the final
senderror by error type; do not wrap it inwhile(true) send().
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) The broker appended, the ack was lost, and the producer retries the same batch: how many records result? (2) What if the app restarts and sends the same eventId again? (3) Why do acks=all and idempotence solve two different problems?