Producer Idempotence Internals: PID & Sequence Numbers in Kafka
How a Kafka broker recognizes a message it has already stored, using a producer identity, an epoch, and a running sequence number, so that retries after network trouble never leave duplicate records in your topics.
Imagine you post an important parcel and the post office gives you a tracking slip. The next day you cannot tell whether the parcel arrived, so you post an identical parcel with the same slip number written on it. A careful sorting office looks at the slip, sees it already handled that number, and quietly throws the second parcel away. The recipient receives exactly one parcel, even though you sent two. You never had to know whether the first one made it.
Kafka’s idempotent producer works in precisely this spirit. Every producer gets an identity, every message batch it sends carries a running number, and every broker remembers the most recent numbers it has seen. When the same batch shows up twice, the broker recognizes it and refuses to store it again. This tutorial opens that mechanism up piece by piece, from the vocabulary to the byte-level header fields, the failure cases, and the operational habits that keep it healthy in production.
1The Duplicate Problem
To appreciate the solution, first watch the problem happen in slow motion.
Why a producer retries at all
A Kafka producer sends a batch of messages to the broker that leads a partition. The broker writes the batch to its log, waits for the replicas it needs, and then sends back an acknowledgement. Networks are unreliable, and brokers restart, so any step of that conversation can fail. A producer that gives up at the first failure would lose data, so by design it retries: it sends the very same batch again.
How a retry becomes a duplicate
Retries are safe when the first attempt truly failed. They are dangerous when the first attempt actually succeeded but the acknowledgement got lost on the way back. From the producer’s point of view, a lost request and a lost reply look identical: it simply hears nothing. If it retries in the second case, the broker stores the same messages twice.
You shout an order to a busy kitchen and hear no reply. Either the cook did not hear you, or the cook heard you and their answer got lost in the noise. If you shout again, in the first case you get your meal, and in the second case you get two meals. Without a way to tell the cases apart, retrying carries a risk.
sequenceDiagram
participant P as Producer
participant B as Broker (partition leader)
P->>B: Send batch with 3 messages
B->>B: Write batch to log (offsets 40 to 42)
B--xP: Acknowledgement lost on the network
Note over P: No reply before timeout, assume failure
P->>B: Retry the same 3 messages
B->>B: Write again (offsets 43 to 45)
B-->>P: Acknowledged
Note over B: The log now holds every message twice
Why duplicates hurt
Money counted twice
A payment event stored twice can lead to a customer being charged twice or a ledger showing an inflated balance.
Wrong analytics
Counters, sums, and dashboards built from the topic are inflated in ways that are hard to notice and harder to correct.
Repeated side effects
Downstream services may send an email twice, ship an order twice, or trigger an alert twice for one real event.
Broken ordering assumptions
A retry can land after later messages, so a sequence such as “created, updated, deleted” might appear out of order in the log.
Many people believe duplicates only happen when something is badly broken. In truth, one brief network stall or a leader election during a send is enough. At scale, with millions of batches a day, such moments are routine rather than rare.
The two old options
Before idempotence, a team had to pick one of two unpleasant trade-offs. Turn retries off, and any failure could lose data. Keep retries on, and some duplicates would appear. Pushing the burden of deduplication onto every consumer was possible, but it meant every downstream team had to build and maintain its own logic. The idempotent producer moves that responsibility to the place where it can be solved cheaply and once: the broker.
2Delivery Semantics in Plain Words
Three phrases appear in every messaging discussion. Here is what each one really promises.
| Guarantee | What happens on failure | Risk | Real-life picture |
|---|---|---|---|
| At most once | Do not retry, accept the loss | Messages can disappear | Dropping a letter in a mailbox and never checking |
| At least once | Retry until confirmed | Messages can be duplicated | Sending the parcel again whenever you are unsure |
| Exactly once | Retry safely, remove duplicates | Needs extra machinery and has boundaries | Sending again, with a tracking slip that lets the office discard repeats |
A teacher collects homework by asking students to put it in a tray. If nobody checks whether homework arrived, some may be lost. If students resubmit whenever unsure, the tray gets copies. If each sheet carries the student’s name and a number, the teacher can quickly notice and discard copies, so the tray holds each sheet once.
Where idempotence sits
The idempotent producer gives you exactly once within a single producer session, for a single partition. That is a smaller promise than “exactly once everywhere”, but it is the essential building block on which larger guarantees, like transactions, are built. Understanding its exact edges is one of the goals of this tutorial, and later chapters return to them in detail.
The word idempotent comes from mathematics and simply means “doing it twice has the same effect as doing it once”. Pressing an elevator’s call button ten times summons the elevator once. That is the behavior we want when a producer sends the same batch twice.
3Core Vocabulary You Need
Each term below is defined once here and then used consistently in the rest of the tutorial.
Producer ID (PID)
A large number assigned by the cluster to identify one producer instance. It is the “name on the tracking slip”.
Producer epoch
A small counter attached to the PID that grows when the same identity is renewed, letting brokers reject older holders of that identity.
Sequence number
A running counter per producer and per partition that numbers each record, starting at zero.
Base sequence
The sequence number of the first record inside a batch. The rest of the batch follows consecutively.
Record batch
The unit of sending and storing in Kafka: a group of records wrapped with a header that carries the PID, epoch, and base sequence.
Producer state
The memory a broker keeps for each producer and partition: the latest sequences, offsets, and epoch it has seen.
In-flight request
A batch that has been sent to a broker but not yet acknowledged. The number of these allowed at once is capped.
Fencing
Rejecting messages from an old holder of an identity because a newer holder, with a higher epoch, has taken over.
Transactional ID
A name chosen by the application, used by transactional producers so the same logical producer keeps its identity across restarts.
Duplicate detection window
The number of most recent batches per producer and partition that a broker can compare an incoming batch against.
Do not confuse a producer ID with a client ID. A client ID is a friendly label you choose for logs and quotas, and it does not affect deduplication. The PID is assigned by the cluster and is invisible to most applications.
4The Big Picture: Three Simple Ideas
The whole mechanism rests on identity, numbering, and memory.
Idea one: identity
Before sending anything, the producer asks the cluster for a producer identity. From then on, every batch it sends says “I am producer number 4711, in epoch 0”. This tells the broker whose numbering to look at.
Idea two: numbering
For each partition it writes to, the producer keeps a counter. The first record sent to partition 3 is number 0, the second is number 1, and so on. Every batch states the number of its first record and how many records it holds, so the numbers of a batch are fully known.
Idea three: broker memory
For every producer and partition, the broker remembers the number of the last record it stored, and the details of a few recent batches. When a new batch arrives, the broker compares its numbers with what it remembers, and decides whether it is the expected next batch, a repeat of one already stored, or something unexpected.
A cinema usher checks tickets. Each ticket has a seat number and a show id. The usher keeps a list of seats already occupied. If the same ticket arrives twice, the usher knows the seat is taken and turns the second copy away politely, without needing to remember the face of the guest.
flowchart TD
A["Producer starts"] --> B["Ask cluster for a producer identity"]
B --> C["Receive PID and epoch"]
C --> D["Number records per partition from zero"]
D --> E["Send batch stamped with PID, epoch, base sequence"]
E --> F{"Broker compares with its memory"}
F -->|"Next expected"| G["Store and remember the new numbers"]
F -->|"Already seen"| H["Do not store, reply success with original offset"]
F -->|"Gap or too old"| I["Reject with an error"]
What the user has to do
In modern Kafka versions, almost nothing. The setting that turns on idempotence is enabled by default in current client releases, as long as the other related settings are compatible. Yet knowing what happens under the hood is essential, because the guarantee has boundaries, and some common configurations quietly switch the feature off or weaken it.
5Architecture and Components
Several parts of Kafka cooperate to make idempotence work. Knowing who does what makes debugging much easier.
On the producer side
Record accumulator
Gathers records into batches per partition. The batch is the unit that receives numbers, so this is where sequences are attached.
Sender thread
A background thread that drains ready batches and ships them to the right brokers, tracks in-flight requests, and handles retries.
Transaction manager
Despite its name, this component also serves plain idempotent producers. It holds the PID and epoch, hands out sequence numbers, and drives recovery after errors.
On the broker side
Any broker (for identity requests)
Answers the initial request for a producer identity for plain idempotent producers, and hands out an unused ID.
Transaction coordinator
Takes over identity management when an application sets a transactional ID, mapping that name to a stable PID and managing its epoch.
Partition leader log
The place where batches are validated, appended, and acknowledged. It owns the authoritative view of the sequence numbers for that partition.
Producer state manager
The per-partition structure that holds the remembered numbers for every producer, and writes its own snapshots to disk.
Follower replicas
Copy batches with their headers intact, and rebuild the same producer state, so a new leader inherits the memory.
Cluster controller
Hands out large blocks of unused producer IDs to brokers, so that two brokers can never give out the same PID.
flowchart LR
subgraph Client["Producer process"]
ACC["Record accumulator"]
SND["Sender thread"]
TM["Transaction manager: PID, epoch, sequences"]
end
subgraph Cluster["Kafka cluster"]
CTRL["Controller: allocates PID blocks"]
BR["Broker: identity requests"]
LEAD["Partition leader: validate, append"]
FOL["Follower replicas: rebuild producer state"]
end
TM --> SND
ACC --> SND
SND -->|"identity request"| BR
BR -->|"needs new block"| CTRL
SND -->|"batches with PID, epoch, sequence"| LEAD
LEAD -->|"replicate batches"| FOL
Assuming the deduplication happens in the client library alone. The client only labels each batch with numbers. All duplicate detection happens on the broker that leads the partition, which is why the guarantee works even if the client restarts its network connection.
6The Producer ID: How Identity Is Assigned
The PID is the anchor of the entire design, so it deserves a careful look.
What a PID is
A PID is a 64-bit whole number. That is huge, over nine quintillion possible values, so the cluster will never run out in practice. It is chosen by the cluster, not by the application, which means no two live producers can accidentally pick the same identity.
The initialization handshake
When an idempotent producer starts sending, its first step is to send an initialize producer ID request to a broker. For a plain idempotent producer, any broker can answer. The broker responds with a fresh PID and an epoch, which begins at zero. The producer stores both in memory and stamps every later batch with them.
sequenceDiagram
participant P as Producer
participant B as Any broker
participant C as Cluster controller
P->>B: Initialize producer ID
alt Broker has unused IDs in its block
B->>B: Take next ID from local block
else Block exhausted
B->>C: Request a new block of IDs
C-->>B: Block of unused IDs
B->>B: Take next ID from new block
end
B-->>P: PID and epoch zero
Note over P: Every future batch carries this PID and epoch
Why IDs come in blocks
If every identity request had to reach the central controller, the controller would become a bottleneck, and a controller outage would stop new producers from starting. Instead, each broker receives a block of consecutive IDs at a time. It can then hand them out locally, without any coordination, and only goes back to the controller when its block runs out. The controller records which blocks it has given away, so no ID is ever handed out twice, even after a crash or failover.
A hospital gives each ward a roll of numbered patient wristbands. The ward hands them out one by one without calling head office each time. When the roll runs out, the ward collects a new roll, and head office notes which rolls have gone to which ward.
A PID is not forever
A plain idempotent producer holds its PID only in memory. If the producer process restarts, it asks for a brand new PID. The broker has no way to link the new identity to the old one. This detail is the source of the most important limitation, explained later: idempotence protects a producer session, not the logical application across restarts.
| Producer kind | Who answers the identity request | Identity survives restart? |
|---|---|---|
| Plain idempotent | Any broker | No, a new PID every time |
| Transactional | The transaction coordinator for the transactional ID | Yes, same PID with a higher epoch |
Each new PID means new state on every partition the producer writes to. Applications that create a fresh producer for every request therefore create an ocean of short-lived identities. Later chapters show why this hurts.
7The Producer Epoch: Fencing Old Identities
A PID says who you are. The epoch says which version of you is currently valid.
What the epoch is for
The epoch is a small counter, a 16-bit number, stored right beside the PID. It exists so the cluster can retire an older holder of an identity without inventing a new PID. Whenever a newer holder appears, it receives a higher epoch, and every broker will refuse any batch stamped with a lower epoch for that PID.
A hotel reissues a room key card whenever a guest loses one. The new card has a higher version number, and the door lock accepts only the latest version. The old card, even if found under a sofa, no longer opens the door.
Where the epoch changes
Transactional producers restarting
When an application starts a producer with the same transactional ID as before, the coordinator returns the same PID with a bumped epoch. Any older instance still running, a so-called zombie, is now fenced, because its epoch is lower.
Recovering from an unknown state
Newer Kafka clients can bump the epoch on their own after certain recoverable errors, such as when a broker forgot the producer’s state. The producer asks for a fresh epoch and restarts its sequence numbers from zero. This allows work to continue without a full restart.
Plain idempotent producers
Because a plain producer receives a brand new PID on each start, its epoch usually stays at its starting value, and fencing across restarts is not needed, since no other instance shares its PID.
flowchart TD
A["Instance A holds PID 88, epoch 3"] --> B["Instance A freezes during a long pause"]
B --> C["Instance B starts with same transactional ID"]
C --> D["Coordinator returns PID 88, epoch 4"]
D --> E["Instance A wakes up and sends epoch 3"]
E --> F{"Broker compares epochs"}
F -->|"3 is lower than 4"| G["Reject with invalid epoch error"]
G --> H["Instance A is fenced and must stop"]
Kafka uses growing numbers to retire the past in several places: leader epochs for partitions, broker epochs for brokers, and controller epochs for the metadata quorum. The producer epoch is the same idea applied to producers.
8Sequence Numbers: The Heart of Deduplication
The sequence number is the number written on the tracking slip. Everything else exists to support it.
One counter per partition
A producer that writes to five partitions keeps five independent counters, one for each partition. Each starts at zero and grows by one for every record sent to that partition. Numbering is per PID and per partition, because the broker that leads one partition knows nothing about the others.
A courier company numbers parcels separately for each delivery route. Parcel 7 for route A and parcel 7 for route B have nothing to do with each other, and the depot for each route only tracks its own list.
Numbering records, stamping batches
Although the counter counts records, the message travels in batches. Each batch carries the sequence number of its first record, called the base sequence, together with the count of records. The broker can therefore compute the last sequence number in the batch, and it knows exactly which range of numbers the batch claims.
| Batch | Records | Base sequence | Last sequence |
|---|---|---|---|
| First batch to partition 2 | 4 | 0 | 3 |
| Second batch | 6 | 4 | 9 |
| Third batch | 2 | 10 | 11 |
| Fourth batch | 5 | 12 | 16 |
The golden rule the broker checks
The broker remembers the last sequence number it stored for this producer and partition. The next batch must begin exactly one higher. If the base sequence is exactly one more than the last stored, the batch is the expected next one. This simple rule catches every kind of problem.
Base equals last plus one
The batch is the next in line. The broker stores it and updates its memory.
Batch matches a remembered one
It is a retry of something already stored. The broker does not store it again, and answers success with the offsets of the first copy.
Base is higher than expected
A batch went missing. Accepting this one would leave a hole, so the broker rejects it with an out-of-order error.
Base is lower and not remembered
It looks like a very old batch that the broker no longer has details about. It cannot safely confirm it, so it rejects.
Sequences wrap around
The sequence number is a 32-bit whole number, roughly two billion values before it wraps. The counter is designed so that, when it reaches its maximum, it wraps back to zero, and the broker’s comparison handles this wrap correctly. A producer that writes a very large number of records to one partition in one session simply cycles through its numbers.
9Inside the Record Batch Header
Everything idempotence needs is carried in a few fixed fields at the front of each batch.
Kafka stores and transmits records in batches, and each batch begins with a header. In the modern format, three of the header fields are dedicated to producer identity. Because the header is written to disk exactly as sent, the identity information becomes part of the permanent log, and this is precisely what allows a broker to rebuild its memory after a restart.
| Header field | Size | Purpose |
|---|---|---|
| Base offset | 8 bytes | Log position of the first record in the batch, filled in by the leader |
| Batch length | 4 bytes | Total size, so readers can skip to the next batch |
| Partition leader epoch | 4 bytes | Which leader wrote this batch, used for truncation logic |
| Magic | 1 byte | Format version, marks this as the modern format |
| Checksum | 4 bytes | Detects corruption |
| Attributes | 2 bytes | Compression, timestamp type, transactional and control flags |
| Last offset delta | 4 bytes | How many records the batch spans |
| First and maximum timestamp | 8 bytes each | Time range of the records |
| Producer ID | 8 bytes | Identity of the producer that wrote the batch |
| Producer epoch | 2 bytes | Version of that identity |
| Base sequence | 4 bytes | Sequence number of the first record |
| Record count | 4 bytes | How many records follow |
A parcel’s shipping label lists the sender number, the label version, and the parcel’s position in the sender’s series. The label is stuck on the box and stays there for the parcel’s entire journey, so any sorting office along the way can read it without opening the box.
Why the header is written to disk
Because the producer fields are stored with every batch, the log itself is a complete record of who wrote what and in what order. If a broker loses its in-memory state, it can walk through the log and reconstruct the memory it needs. Followers receive the exact same bytes, header included, so they can build the same memory as well.
Kafka’s log dump tool shows these header fields for each batch in a partition’s log file. It is a very direct way to see the PID, epoch, and sequence numbers of real data, and it is invaluable when investigating suspected duplicates or ordering problems.
flowchart LR
A["Base offset"] --> B["Length"]
B --> C["Leader epoch"]
C --> D["Magic and checksum"]
D --> E["Attributes"]
E --> F["Offset delta and timestamps"]
F --> G["Producer ID"]
G --> H["Producer epoch"]
H --> I["Base sequence"]
I --> J["Record count"]
J --> K["Records"]
10The Send Path, Step by Step
Follow one message from the moment the application hands it to the producer until the acknowledgement returns.
Application calls send
The record is serialized, and a partition is chosen from the key or by the partitioner.
Record joins a batch
The accumulator adds it to the open batch for that partition, waiting until the batch is full or a short delay passes.
Batch is ready and numbered
When the sender drains the batch, the producer assigns the base sequence from its per-partition counter and stamps the PID and epoch. Once assigned, these numbers stay with the batch for all its retries.
Sent to the partition leader
The batch travels in a produce request, together with other ready batches for the same broker.
Broker validates
The leader compares the PID, epoch, and sequence against its memory, and decides to store, treat as duplicate, or reject.
Replicate and acknowledge
Followers copy the batch. Once the required replicas hold it, the leader acknowledges. The producer then completes the callback and advances its state.
sequenceDiagram
participant App as Application
participant Acc as Accumulator
participant Snd as Sender
participant L as Partition leader
participant F as Follower
App->>Acc: send record
Acc->>Acc: add to batch for partition
Snd->>Acc: drain ready batch
Snd->>Snd: stamp PID, epoch, base sequence
Snd->>L: produce request
L->>L: validate sequence against memory
L->>L: append batch and update memory
F->>L: fetch new batch
L-->>Snd: acknowledgement with offset
Snd-->>App: callback success
Numbers are attached when the batch is first sent, and they never change on retry. If numbers were reassigned on retry, the broker would see a “new” batch and store it again, defeating the whole idea. A retried batch is byte-for-byte the same batch with the same numbers.
11Broker-Side Validation: The Decision Tree
Every batch arriving at a partition leader goes through a small set of questions, always in the same order.
The remembered state
For each producer that has written to a partition, the leader keeps a small entry. It holds the producer’s latest epoch, the last sequence number stored, and compact details of up to five of the most recent batches: their first and last sequence numbers and the offsets where they landed. These details let the broker answer a retry with the correct original offset, rather than a made-up one.
flowchart TD
A["Batch arrives with PID, epoch, base sequence"] --> B{"Does broker know this PID for this partition?"}
B -->|"No, and base sequence is zero"| C["Accept as first batch, create entry"]
B -->|"No, and base sequence not zero"| D["Reject: unknown producer or out of order"]
B -->|"Yes"| E{"Compare epoch"}
E -->|"Lower than stored"| F["Reject: producer fenced"]
E -->|"Higher than stored"| G["Reset entry, accept only if base sequence is zero"]
E -->|"Equal"| H{"Compare base sequence with expected"}
H -->|"Equals last plus one"| I["Accept, append, update entry"]
H -->|"Matches one of last five batches"| J["Duplicate: skip append, return original offset"]
H -->|"Otherwise"| K["Reject: out of order sequence"]
Outcomes at a glance
| Situation | Broker action | What the producer sees |
|---|---|---|
| Expected next batch | Append and remember | Normal success |
| Exact repeat of a recent batch | Skip append | Success, with the original offset |
| Sequence gap | Reject | Out-of-order sequence error |
| Lower epoch than known | Reject | Invalid epoch or fenced error |
| PID not known and sequence not zero | Reject | Unknown producer error |
| Higher epoch than known | Reset producer state, then evaluate | Success if the sequence starts at zero |
A bouncer at a club holds a clipboard. A guest with the next ticket number walks in. A guest with a ticket number already crossed off is told “you are already inside”, and is politely sent to their table rather than admitted again. A guest whose number jumps far ahead is stopped and asked what happened to the numbers in between.
Why rejecting gaps matters
A gap means some earlier batch never reached the broker. If the broker accepted the later batch anyway, the log would silently miss messages and the order of events would be wrong. By rejecting, the broker forces the producer to resend from the point of the gap, which preserves both completeness and order.
12Retries Under Two Kinds of Failure
Return to the problem from chapter one and watch the same failures play out with idempotence turned on.
Failure kind one: the request is lost
The batch never reaches the broker. After a timeout, the producer retries with the same numbers. The broker sees a batch with exactly the next expected sequence, and stores it. There is no duplicate, because the first attempt never happened.
Failure kind two: the reply is lost
The batch reaches the broker and is stored, but the acknowledgement vanishes. The producer retries with the same numbers. This time the broker finds that the batch matches one it remembers, and skips the append. It replies success, reporting the original offset. The producer believes its retry worked, which in a sense it did, and the log contains the messages exactly once.
sequenceDiagram
participant P as Producer
participant B as Broker
P->>B: Batch PID 7, epoch 0, base sequence 20, 3 records
B->>B: Accept, store at offsets 100 to 102, remember sequences 20 to 22
B--xP: Acknowledgement lost
Note over P: Timeout, retry with identical numbers
P->>B: Same batch PID 7, epoch 0, base sequence 20, 3 records
B->>B: Matches remembered batch, do not append
B-->>P: Success with offsets 100 to 102
Note over B: Log holds the three records once
Failure kind three: reordering during retries
Suppose two batches are in flight, batch A with sequences 20 to 22 and batch B with sequences 23 to 25. Batch A fails and is retried, but batch B arrives first. Without idempotence, batch B would be stored before batch A, reversing the order. With idempotence, the broker sees that B’s base sequence, 23, does not equal the expected 20. It rejects B with an out-of-order error. The producer resends A and then B, and the log ends up in the correct order.
Lost request
Retry stored once. Order kept.
Lost reply
Retry recognized as a repeat and skipped. Original offsets returned.
Overtaking batches
Later batch rejected until the earlier one lands. Order kept.
Broker restart mid-flight
Producer state is rebuilt from the log, so repeated batches are still recognized.
Idempotence gives you not just removal of duplicates but also preserved order within a partition when retries are involved. Before it existed, teams that needed strict order had to allow only one request in flight at a time, at a real cost in throughput.
13In-Flight Requests and the Five-Batch Window
Why does Kafka insist on a small number, five, when it comes to outstanding requests?
What “in flight” means
To use the network efficiently, a producer does not wait for one request to be acknowledged before sending the next. It keeps several requests to the same broker outstanding at once. A setting controls how many such unacknowledged requests are allowed per connection.
A cashier at a busy till can pass several receipts to a bagging assistant before the first bag is handed over. More receipts in progress means a faster queue, but the assistant needs a system for tracking which bag belongs to which receipt.
The link with the broker’s memory
The broker remembers the details of only the last five batches per producer and partition. That number is closely tied to the limit on in-flight requests. If a producer never has more than five batches outstanding, then any batch that might be retried is guaranteed to be among the five the broker still remembers. The broker can therefore always tell a retry from a genuinely new batch.
flowchart LR
subgraph Window["Broker remembers last five batches"]
B1["Batch 1"]
B2["Batch 2"]
B3["Batch 3"]
B4["Batch 4"]
B5["Batch 5"]
end
N["New batch arrives"] --> Q{"Matches one in the window?"}
Q -->|"Yes"| D["Duplicate: acknowledge without storing"]
Q -->|"No, and expected next"| S["Store and slide the window forward"]
Q -->|"No, and not expected"| R["Reject"]
What happens if you go beyond the limit
With more than five batches in flight, a retry of the oldest one could arrive after the broker has already pushed it out of memory. The broker could then no longer recognize it as a repeat. Modern clients therefore refuse to enable idempotence when the setting for in-flight requests is above five, and older clients treat it as a configuration error.
| Max in-flight per connection | Throughput | Order with retries and idempotence | Allowed with idempotence? |
|---|---|---|---|
| 1 | Lowest | Preserved | Yes |
| 2 to 5 | Good to high | Preserved thanks to sequence checks | Yes |
| Above 5 | Higher, but not useful in most setups | Cannot be guaranteed | No |
The default of five in-flight requests is a sweet spot: it lets the network stay busy and it fits perfectly inside the broker’s memory of five batches. Lowering it rarely buys anything beyond lower throughput, and raising it is not permitted.
14Producer State on the Broker: Memory, Snapshots, Expiry
The broker’s notebook must survive restarts and must not grow forever. Both needs shape the design.
Where the state lives
Each partition replica has a producer state manager. It holds a map from PID to that producer’s entry. The map lives in memory for speed, but the truth is always recoverable from the log, because every batch header contains the PID, epoch, and sequence numbers.
Snapshots to speed up recovery
If a broker had to scan the entire log after every restart to rebuild its notebook, startup would be painfully slow for partitions holding many gigabytes. To avoid this, the broker periodically writes a producer state snapshot file next to the partition’s log segments, usually when a log segment rolls over. On restart, the broker loads the latest snapshot and replays only the batches written after it.
Batches are appended
Each accepted batch updates the in-memory entry for its producer.
A segment rolls
When the active log segment is closed and a new one begins, the broker writes a snapshot of the whole producer map.
Restart happens
The broker loads the most recent snapshot as its starting point.
Replay the tail
Batches written after the snapshot are read from the log, and the entries are brought fully up to date.
A shop counts its stock at the end of each week and files the count. After a power cut, the manager takes the latest count and adds up only the sales since then, instead of recounting every item in the warehouse from scratch.
Expiry keeps the notebook small
A busy cluster sees a large number of producers come and go. If the broker remembered every producer forever, the memory would grow without limit. Entries are therefore removed after a period of inactivity, controlled by an expiration setting whose default is measured in a day. Entries can also disappear when the log data they describe has been deleted by retention.
flowchart TD
A["Producer writes to partition"] --> B["Entry created or refreshed with last write time"]
B --> C{"No writes for longer than expiry period?"}
C -->|"No"| B
C -->|"Yes"| D["Entry removed from broker memory"]
D --> E["Producer returns later with same PID"]
E --> F{"Sequence is zero?"}
F -->|"Yes"| G["Treated as a fresh producer"]
F -->|"No"| H["Unknown producer error, client must recover"]
Assuming a producer that has been quiet for a long time can simply resume. After its state expires, the broker no longer knows the PID, and the client will face an error unless it starts numbering from zero again. Recent clients handle this by requesting a new epoch automatically, but the situation still shows up in logs and metrics.
Memory cost
Each entry is small, only a handful of numbers and five compact batch summaries, but the total count is the number of producers multiplied by the number of partitions they touch. A cluster with a large number of short-lived producers writing to many partitions can accumulate a surprising amount of state, which is why controlling producer churn is a recurring operational theme.
15Leader Failover and Replication
The guarantee must survive the death of the very broker that holds the notebook. Here is how.
Followers keep their own notebooks
Follower replicas copy batches from the leader byte for byte, including the header that holds the PID, epoch, and sequence numbers. As each batch is appended to a follower’s log, the follower also updates its own producer state. So at any moment, every in-sync replica holds a nearly identical notebook, differing only by the last few batches still being copied.
What happens at failover
Suppose the leader dies just after storing a batch and just before acknowledging it. A follower that had already copied the batch becomes the new leader. The producer, having no acknowledgement, retries. The retry arrives at the new leader, which recognizes the numbers from its own notebook, and skips the append. There is no duplicate, even though the broker that first stored the batch is gone.
sequenceDiagram
participant P as Producer
participant L1 as Old leader
participant L2 as Follower who becomes leader
P->>L1: Batch with sequences 50 to 54
L1->>L1: Append batch, update notebook
L2->>L1: Fetch, copies batch and updates own notebook
L1--xP: Leader crashes before acknowledging
Note over L2: Elected new leader
P->>L2: Retry same batch, sequences 50 to 54
L2->>L2: Notebook shows this batch already stored
L2-->>P: Success with original offsets
The acknowledgement setting matters
The story above assumes the follower had received the batch before the leader failed. Idempotence therefore requires the strongest acknowledgement setting, in which the leader answers success only after all in-sync replicas hold the batch. With weaker settings, the leader might acknowledge a batch that no follower has yet copied. If that leader then dies, the batch is lost, and the producer, which thinks it succeeded, will have sent sequence numbers that the new leader has never seen. This mismatch can surface as an out-of-order error or as silent data loss.
With the strongest acknowledgements
- Acknowledged batches exist on every in-sync replica
- A new leader inherits the notebook for those batches
- Retries after failover are correctly recognized
- Combines well with a minimum in-sync replica setting
With weak acknowledgements
- Leader may acknowledge before followers copy
- Failover can lose acknowledged data
- Producer and new leader disagree about sequence numbers
- Idempotence cannot be safely enabled
This is why the client refuses to turn on idempotence when a weaker acknowledgement level has been explicitly requested. Deduplication is only meaningful if acknowledged data is durable across leader changes.
Unclean leader election
If the cluster allows a replica that was out of sync to become leader, it may lack batches that the previous leader had acknowledged. The producer’s next batch then has a base sequence that is higher than that leader expects, and the leader rejects it. Recent clients treat this as a recoverable problem in many cases, but it is a sign that data has been lost, and it deserves investigation.
16Errors and Recovery
Knowing the names of the errors, and what each one means, makes log messages far easier to read.
Out of order sequence
The batch’s numbers do not fit the broker’s notebook. Either a batch is missing, or the broker lost state. The producer usually retries earlier batches first.
Unknown producer
The broker has no notebook entry for this PID, often because the entry expired or the log data was deleted by retention.
Invalid producer epoch
The producer holds an older epoch than the broker knows about, meaning a newer holder of that identity exists and this one is fenced.
Timeout on delivery
The producer’s overall time limit for delivering a batch expired before it could be confirmed, even though retries were allowed.
How the client reacts
Retriable problems
Temporary issues such as a leader election or a network drop are simply retried with identical numbers, up to the delivery time limit. This is the everyday case, and the application never notices.
Sequence problems after a timeout
If a batch expires while it might still have reached the broker, the producer cannot know whether the broker stored it. Newer clients then request a fresh epoch and restart numbering, accepting the small risk that a previously sent copy may already exist, in exchange for keeping the producer usable.
Fencing
A fenced producer knows a newer instance has taken over. It cannot continue, and the application should stop that instance, since continuing would risk conflicting writes.
flowchart TD
A["Produce request fails"] --> B{"Error kind"}
B -->|"Temporary network or leader change"| C["Retry with identical numbers"]
B -->|"Out of order sequence"| D["Resend earlier batches in order, or bump epoch if state was lost"]
B -->|"Unknown producer"| E["Request new epoch and restart numbering"]
B -->|"Invalid epoch, fenced"| F["Stop the producer, a newer instance owns the identity"]
B -->|"Delivery time limit exceeded"| G["Fail the batch to the application, bump epoch if needed"]
C --> H["Success or eventually one of the other branches"]
When the producer reports that a send finally failed, the application must decide what to do. Resending the record from application code creates a brand new record with new numbers, which the broker cannot recognize as a duplicate. Duplicates created that way are the application’s doing, not the producer’s.
17Configuration and Defaults
A handful of settings decide whether idempotence is on and how it behaves.
| Setting | Role | Requirement for idempotence |
|---|---|---|
enable.idempotence | Turns the mechanism on or off | On by default in recent client releases |
acks | How many replicas must hold a batch before acknowledgement | Must be the strongest level, all in-sync replicas |
retries | How many times a failed send is retried | Must be greater than zero, default is effectively unlimited |
max.in.flight.requests.per.connection | Outstanding requests per broker connection | Must be five or fewer |
delivery.timeout.ms | Overall time budget for delivering a batch, including retries | Bounds how long a retry loop may continue |
transactional.id | Names a transactional producer | Implies idempotence, and adds stable identity |
min.insync.replicas (topic or broker) | Minimum replicas that must be in sync to accept writes | Not required, but strongly advised alongside the strongest acknowledgements |
What happens with conflicting settings
If you explicitly request idempotence together with an incompatible value, such as a weaker acknowledgement level, the client raises a configuration error at startup. If you leave idempotence unset and choose a conflicting value, recent clients quietly fall back to non-idempotent behavior rather than fail. This silent fallback is a classic trap, because an application may believe it has protection when it does not. Always verify the effective configuration in the startup logs of your producers.
A single copy-pasted configuration line that sets a weaker acknowledgement level can turn idempotence off without any loud complaint. Check the logged producer configuration after every change, and consider setting idempotence explicitly to true, so that a conflict becomes an error instead of a silent downgrade.
Older client versions
In older releases idempotence was off by default and had to be enabled by hand. Applications upgraded to newer clients may therefore change their behavior after the upgrade, gaining protection they did not previously have. Read release notes when moving between major versions, and re-test producer behavior under failure.
18What Idempotence Does Not Protect
A guarantee is only useful when you know its boundaries. These are the most important ones.
Situation
A plain idempotent producer crashes after sending a batch but before receiving the acknowledgement. The application restarts and sends the same business event again.
Why It Is Not Covered
The restarted producer is given a brand new PID, and its numbering starts from zero. The broker sees a totally unrelated producer and stores the batch. No notebook entry links the two lives of the application.
What To Do
Use a transactional ID for a stable identity, or make consumers tolerate duplicates by carrying a business key that lets them detect repeats.
Situation
The application itself calls send twice for the same business event, for example after a user double-clicks a button.
Why It Is Not Covered
Each call is a separate record with its own sequence number. From the broker’s view, they are two legitimate, different messages.
What To Do
Deduplicate at the source with a unique request identifier, or design consumers to be idempotent themselves.
Situation
A producer writes to several partitions or topics, and a failure leaves some writes done and others not.
Why It Is Not Covered
Sequence numbers are tracked separately per partition. Idempotence guarantees that each partition sees each batch once, but it offers no all-or-nothing guarantee across partitions.
What To Do
Use transactions when writes across partitions must be atomic.
Situation
A consumer reads a message, updates a database, and crashes before committing its position. After restart it reads the message again.
Why It Is Not Covered
The producer’s guarantee ends at the log. The consumer side has its own retry behavior, and reprocessing is a separate topic.
What To Do
Make consumer processing idempotent, or use transactions that tie consumed positions and produced results together.
Idempotence stops the producer’s own retries from creating duplicates within one producer life and one partition. It does not stop the application, the network of consumers, or a restarted producer from creating a second copy of the same business event.
19From Idempotence to Transactions
Transactions build on the same three ideas: identity, epoch, and sequence numbers.
What transactions add
A transactional producer receives a name, the transactional ID, chosen by the application. A dedicated broker role, the transaction coordinator, maps that name to a PID and keeps the mapping in an internal log. When the application restarts with the same name, it gets the same PID and a higher epoch. This gives a stable identity across restarts, and it fences older instances.
| Capability | Idempotent only | Transactional |
|---|---|---|
| Dedup of retries within a session | Yes | Yes |
| Stable identity across restarts | No | Yes |
| Fencing of zombie instances | Not needed | Yes, through epochs |
| Atomic writes across partitions | No | Yes |
| Consume, process, and produce atomically | No | Yes, with offsets included in the transaction |
| Extra cost | Very small | Coordinator round trips and markers |
flowchart TD
A["Idempotence: PID and sequence numbers"] --> B["Add a named identity: transactional ID"]
B --> C["Stable PID, epoch increases on each restart"]
C --> D["Zombie instances are fenced"]
C --> E["Group writes into transactions"]
E --> F["Commit or abort markers written to each partition"]
F --> G["Consumers can read only committed data"]
Idempotence is the tracking slip that stops the sorting office from processing the same parcel twice. Transactions add a signed delivery receipt covering several parcels together, so that either all of them are delivered and confirmed, or none of them count.
Idempotence is always inside
A transactional producer cannot work without idempotence. Every write inside a transaction still carries a PID, epoch, and sequence number, and the broker still validates them in exactly the way described earlier. Transactions simply add markers and a coordinator on top. Understanding the idempotent producer thoroughly is therefore the best preparation for understanding transactions.
20A Worked Example With Real Numbers
Abstract rules become easy once you watch the numbers move through a small, concrete story.
The setting
A payment service uses one idempotent producer. The cluster hands it PID 5001 with epoch 0. The service writes to a topic with three partitions, so the producer keeps three independent counters, all starting at zero. The leader of partition 1 is broker A, and broker B holds a follower replica of partition 1.
Round one: a normal batch
The producer collects four records for partition 1. It stamps the batch with PID 5001, epoch 0, base sequence 0, and a count of four. Broker A sees an unknown producer with base sequence zero, which is the legal way to begin. It appends the batch at log offsets 900 to 903 and creates a notebook entry that says: producer 5001, epoch 0, last sequence 3, last offset 903. Broker B copies the batch and builds the same entry.
| Step | Batch stamp | Broker A notebook after the step | Outcome |
|---|---|---|---|
| 1 | PID 5001, epoch 0, base 0, 4 records | Last sequence 3, last offset 903 | Stored at offsets 900 to 903 |
| 2 | PID 5001, epoch 0, base 4, 2 records | Last sequence 5, last offset 905 | Stored at offsets 904 and 905 |
| 3 | Same as step 2, retried after lost reply | Unchanged: last sequence 5, last offset 905 | Duplicate skipped, success returned with offsets 904 and 905 |
| 4 | PID 5001, epoch 0, base 9, 3 records | Unchanged | Rejected: expected base 6, got 9 |
| 5 | PID 5001, epoch 0, base 6, 3 records | Last sequence 8, last offset 908 | Stored at offsets 906 to 908 |
| 6 | Retry of step 4, now with base 9 | Last sequence 11, last offset 911 | Stored at offsets 909 to 911 |
What the table teaches
Step three shows deduplication: the broker recognized the batch, did not add it again, and returned the offsets of the first copy. Step four shows gap detection: a later batch arrived early and was refused. Step five shows the missing batch arriving, and step six shows the earlier rejected batch succeeding once its predecessor landed. The final log holds every record exactly once and in the intended order, despite one lost reply and one overtaking batch.
sequenceDiagram
participant P as Producer PID 5001
participant A as Broker A (leader)
P->>A: Base 0, 4 records
A-->>P: Stored at 900 to 903
P->>A: Base 4, 2 records
A->>A: Stored at 904 and 905
A--xP: Reply lost
P->>A: Base 9, 3 records (sent while waiting)
A-->>P: Rejected, expected base 6
P->>A: Retry base 4, 2 records
A-->>P: Duplicate, success with 904 and 905
P->>A: Base 6, 3 records
A-->>P: Stored at 906 to 908
P->>A: Retry base 9, 3 records
A-->>P: Stored at 909 to 911
Now add a failover
Imagine broker A crashes right after step five, before acknowledging it. Broker B, which had copied that batch, becomes leader. Its notebook says the last sequence is 8. The producer retries the batch with base 6, and broker B recognizes it, returning success without a second copy. Next the producer sends base 9, which is exactly the expected next value, and the story continues as if nothing happened.
Now add a producer restart
Suppose the payment service restarts. The new producer asks for identity and receives PID 5002. Its counters start at zero. If it now resends a payment event that the old producer already wrote, the broker sees producer 5002 with base sequence zero, which is perfectly valid, and stores it. Two copies exist. This is the boundary described earlier, made visible in numbers: the notebook has entries for 5001 and 5002, and nothing links them.
The protection is strong inside a producer’s life and silent across lives. If a business event might be resent by a new producer instance, give that event a unique business key and make the consumer check it, or use a transactional ID.
21Client Internals: Accumulator, Sender, and Recovery Logic
Inside the producer library, a few carefully arranged rules keep the sequence numbers honest.
Numbers are assigned late
Records sit in the accumulator for a short time before they are drained into a request. Sequence numbers are assigned when a batch is drained for its first send, not when the record is first handed to the producer. This ordering matters: the number a batch receives reflects the order in which batches actually leave for the partition, which is the order the broker will validate.
Keeping retries in order
When a batch must be retried, the producer places it back at the front of the queue for its partition. Batches with earlier sequence numbers are always sent before batches with later ones. If it did not do this, its own retries would keep colliding with the broker’s gap checks. The sender also tracks, per partition, the highest sequence number that has been acknowledged, so it knows which in-flight batches are safe to forget.
flowchart TD
A["Batch drained from accumulator"] --> B["Assign base sequence from partition counter"]
B --> C["Send to leader, remember as in flight"]
C --> D{"Reply"}
D -->|"Success"| E["Remove from in flight, advance acknowledged sequence"]
D -->|"Retriable error"| F["Put batch back at front of partition queue, keep same numbers"]
F --> C
D -->|"Delivery time limit reached"| G["Fail batch, decide about epoch bump"]
G --> H["Later batches for this partition are reset with new epoch and fresh numbers"]
When a batch cannot be confirmed
The trickiest moment is when a batch has used up its delivery time and the producer still does not know whether the broker stored it. The producer cannot simply forget the batch and move on, because later batches have already been numbered on the assumption that this one exists. If the broker never received it, those later batches would now show a gap. The producer therefore has two choices: keep trying until it learns the answer, or bump the epoch and renumber everything that follows from zero. Recent clients choose the second path, since it lets the producer continue, and they accept a small, understood risk in exchange.
Why bumping the epoch is safe
A new epoch means the broker discards its old notebook entry for that PID and starts fresh. All later batches begin at sequence zero, and no gap can arise. The cost is that the broker can no longer tell whether the unconfirmed batch was stored before, so in rare cases a copy might exist. The producer reports the expired batch as failed to the application, so the application is aware that its status is uncertain.
Who coordinates the bump
The client asks the cluster for a new epoch for its existing PID. For transactional producers, the transaction coordinator handles it. For plain idempotent producers, recent versions support the same idea through the identity request, so the producer keeps its PID but gets a higher epoch.
A courier numbers a series of parcels 1 to 10. Parcel 4 vanishes and nobody can tell whether it reached the depot. Rather than leaving a hole in the series that would block all later parcels, the courier starts a new series with a new colored label, and notes that parcel 4’s fate is unknown.
Partition changes and numbering
The counters are per partition, so when a topic gains partitions, the producer simply begins a new counter for each new partition it writes to. Changing which partition a given key maps to does not disturb existing counters. The producer’s memory of counters grows with the number of distinct partitions it writes to, which is another reason to keep the count of partitions per producer reasonable.
All of this recovery happens inside the client library. The application sees either normal success, or a failure callback for a specific record. It should treat the failure callback as “outcome uncertain” when the reason is a timeout, and design its next step accordingly.
22Idempotence Compared With Other Deduplication Strategies
Idempotent producers are one tool among several. Knowing the others helps you pick the right mix.
| Strategy | Where it acts | Strength | Weakness |
|---|---|---|---|
| Idempotent producer | Broker, per producer session and partition | Automatic, cheap, covers retry duplicates and order | Does not span restarts, partitions, or application resends |
| Transactions | Broker and coordinator, across partitions and restarts | Atomic multi-partition writes and stable identity | More moving parts and higher latency |
| Consumer-side business key check | Consumer or downstream store | Handles duplicates from any origin | Needs storage of seen keys and code in every consumer |
| Database unique constraint | Target database | Very strong and simple for sinks that support it | Only helps where the sink can enforce uniqueness |
| Log compaction by key | Topic retention | Keeps only the latest value for each key over time | Duplicates remain visible until compaction runs |
| Outbox pattern | Application database | Ties event publishing to database changes safely | Requires a relay process and still needs downstream dedup |
Choosing a combination
Idempotent producer plus consumer keys
The most common and practical mix. Retry duplicates disappear at the source, and any remaining repeats are caught downstream by a business key.
Transactions plus read-committed consumers
For pipelines that read, process, and write within Kafka and need atomic results. Adds stable identity, fencing, and all-or-nothing behavior.
Outbox plus idempotent producer plus unique keys
For services that must publish an event whenever their own database changes. The outbox makes publishing reliable, the producer removes retry duplicates, and unique keys catch any remaining repeats.
Idempotent producer plus compaction
For topics that represent the latest state of an entity, where the newest value for a key is all that matters over time.
Where to put the effort
The closer to the source you remove a duplicate, the cheaper it is, because fewer systems ever see it. That is the argument for idempotent producers: they stop the most common duplicates before they enter the log. Yet duplicates from application logic, restarts, or consumer reprocessing sit outside the producer’s view, so a prudent design adds a second, downstream line of defense for the data that truly matters, such as money.
Ask, for each topic: what does a duplicate cost? For log lines, almost nothing, and the default idempotent producer is plenty. For payments, quite a lot, and a business key check is worth building even though idempotence is on.
23How the Feature Evolved Across Kafka Versions
Knowing the history explains many quirks you will meet in older clusters and older documentation.
The original design (2017)
Idempotent producers and transactions arrived together in the 0.11 release, along with the new record batch format that carries the PID, epoch, and sequence fields in the header. The feature was off by default and had to be enabled by hand.
Smarter recovery (2020)
A later improvement let producers bump their epoch after certain errors, rather than becoming permanently unusable. This made idempotent producers far more forgiving of expired state and unclear delivery outcomes.
Idempotence on by default (2021)
With the 3.0 release, the client turned the feature on by default, together with the strongest acknowledgement setting, because the cost was so small and the protection so valuable. The same release added tooling for inspecting active producers on a partition.
Separate expiry for producer state (2023)
Later releases separated the expiration of plain producer state from that of transactional identities, so the two could be tuned independently and so that state from idempotent producers could be cleaned more precisely.
Protection against identity floods (2024 onward)
Newer releases added ways to limit how many new producer identities a client may create in a period, reflecting real incidents in which clients that created producers in tight loops overloaded brokers.
The metadata quorum era
As clusters moved from an external coordination service to the built-in controller quorum, the allocation of producer ID blocks moved to the controller as well, with the same guarantee that no ID is given out twice.
Exact behavior of defaults, tooling, and limits depends on the version of both the client library and the brokers. Always confirm against the release notes for your specific versions before relying on a detail from this tutorial in production.
What changed for you as a user
From opt-in to opt-out
Applications written years ago often set idempotence by hand. Newer applications get it for free. A codebase that mixes both styles may have producers with different behavior, so audit the effective settings across services.
From fatal errors to recoverable ones
Situations that once forced an application restart, such as an expired producer entry, are now often handled inside the client. Error handling code written for old versions may be more defensive than necessary.
From invisible to inspectable
With tooling to list producers per partition, questions like “who is writing to this partition, and when did they last write?” can now be answered directly, which turns many mysteries into short investigations.
24Performance and Resource Costs
Does this safety come at a price? The honest answer is yes, but the price is very small.
Costs on the producer
The producer keeps a counter per partition and a little bookkeeping for batches that are in flight. It must hold on to each unacknowledged batch until confirmed, so that retries can use the identical numbers. These items use a small amount of memory and negligible processing time.
Costs on the wire and on disk
Each batch header carries fourteen extra bytes for the PID, epoch, and base sequence. Since a batch typically holds many records, the added weight per record is tiny. These fields are present in the modern format whether or not idempotence is turned on, so switching the feature on adds nothing further to the stored data.
Costs on the broker
Producer state memory
One small entry per producer per partition replica, with five batch summaries each. Grows with the number of active and recently active producers.
Snapshot files
Small files written at segment rolls, and read on restart. Cheap in normal cases, larger when the producer count is enormous.
Validation work
A handful of integer comparisons per batch. Effectively free next to disk and network work.
Identity requests
One request per new producer instance. Trivial for long-lived producers, noticeable when producers are created and dropped at very high rates.
The real performance trap: producer churn
The overwhelming majority of idempotence-related production problems come from one habit: creating a new producer object for every request, or for every small unit of work. Each new producer asks for a new PID, then creates fresh state on every partition it writes to. If a service creates thousands of such producers per second, the cluster accumulates a heap of short-lived identities. Brokers spend time answering identity requests and storing state that will expire unused, and memory usage climbs.
A producer is designed to be long-lived and shared between threads. Create it once per application instance, keep it for the life of the process, and close it on shutdown. Newer releases also give operators tools to limit the rate at which one client can create new identities, precisely because of this problem.
flowchart LR
subgraph Bad["Producer per request"]
R1["Request 1 creates producer"] --> P1["New PID"]
R2["Request 2 creates producer"] --> P2["New PID"]
R3["Request 3 creates producer"] --> P3["New PID"]
P1 --> S["Thousands of short-lived state entries on brokers"]
P2 --> S
P3 --> S
end
subgraph Good["Shared long-lived producer"]
R4["Requests 1 to N"] --> P4["One producer, one PID"]
P4 --> T["Small stable state on brokers"]
end
Throughput effects of the in-flight limit
Because idempotence caps in-flight requests at five, a very high-latency link between producer and broker can limit throughput. In such cases, larger batches and moderate delays to fill them are more effective than trying to push more requests in flight.
A back-of-envelope sizing exercise
Numbers make the memory cost of producer state easy to feel. Suppose a partition replica has entries for producers that wrote to it during the last expiry period. Each entry holds a producer id, an epoch, a last sequence, a last offset, timestamps, and up to five small batch summaries. Even with generous rounding, an entry is a few hundred bytes. Now multiply by the number of distinct producers and partitions.
| Scenario | Active producers per partition | Partitions per broker | Entries on the broker | Rough meaning |
|---|---|---|---|---|
| Small service fleet | 10 | 500 | 5,000 | Negligible memory |
| Busy platform | 200 | 2,000 | 400,000 | Tens of megabytes, comfortable |
| Producer per request pattern | 50,000 | 2,000 | 100,000,000 | Gigabytes of state, snapshot files swell, restarts slow down |
The lesson from the table is not that idempotence is heavy. The first two rows show it is almost free. The third row shows how one bad habit multiplies a tiny cost by a giant factor. Bounding the count of producers is therefore a better lever than trying to shave bytes from each entry.
When capacity planning a cluster, estimate the number of long-lived producer instances across all your services, multiply by the average number of partitions each one writes to, and watch that product over time. A sudden jump is a much earlier warning than any memory alarm.
25Security Considerations
Identity systems attract attention from attackers and from accidents alike.
Can someone forge a PID?
Producer identities are assigned by the cluster, and the client cannot choose one. A malicious client could try to send batches stamped with a PID it observed elsewhere, but in a secured cluster every connection is authenticated, and every write is authorized against the topic. The PID adds no permission by itself. An attacker who already has write permission to a topic can write to it with or without a PID, so the identity mechanism does not widen access.
Authenticate every client
Use TLS client certificates or SASL, so that every producer connection is tied to a known principal.
Authorize writes
Grant write permission per topic to specific principals. In older versions, an additional cluster-level permission for idempotent writes was also needed when access control was enabled.
Protect transactional IDs
Transactional producers need permission on their transactional ID as a resource. Without this, any authorized client could steal the identity of another application and fence it.
Encrypt in transit
Use TLS so batches, including headers, cannot be captured or replayed by someone on the network.
Limit identity creation
Apply quotas and limits on how quickly one client can create new producer identities, to prevent a misbehaving or malicious client from exhausting broker memory.
Restrict log file access
The log and its producer state snapshots reveal which producers wrote what and when, so operating system permissions on them should be tight.
Idempotence gives a small side benefit: a replayed batch captured from the network, even if an attacker could inject it, would be recognized by its sequence numbers and not stored a second time within the window. Encryption and authentication remain the real defense, but the sequence check adds a layer.
Sharing one transactional ID across different applications or environments. The second application to start will fence the first, and both may fail in confusing ways. Give every logical producer instance a unique, well-managed transactional name.
26Monitoring, Logging, and Metrics
When idempotence works, nothing visible happens. That is why you need signals that show it working and warn when it is not.
On the producer
| Signal | What it shows | Healthy pattern |
|---|---|---|
| Record retry rate | How often records are being resent | Low and flat, spikes match known incidents |
| Record error rate | Records that failed for good | Zero or near zero |
| Request latency | Round trip time to brokers | Stable, no upward drift |
| Batch size and records per request | Efficiency of batching | Steady, near your configured targets |
| Buffer availability | Whether the producer is blocked by full buffers | Comfortable headroom |
| Startup configuration log | The effective settings, including idempotence | Idempotence shown as enabled |
On the broker
Producer state size
Watch how many producer entries partitions hold, and how quickly that number grows. A sudden rise is a strong sign of producer churn or a runaway client.
Error counts by type
Track counts of out-of-order sequence, unknown producer, and invalid epoch errors. A few are normal around failovers. A steady stream indicates a real problem.
Produce request latency and failed produce rate
Standard health indicators that also reflect how often producers are forced to retry.
Under-replicated partitions and leader elections
These events cause retries and can trigger sequence errors, so correlate producer errors with replication health.
Tools that ship with Kafka
Log dump tool
Prints batch headers, including producer ID, epoch, and base sequence, straight from log segment files, and can also print the contents of producer state snapshots.
Transactions tool
Can list active producers on a partition, showing their IDs, epochs, last sequence numbers, and last write times, and can describe transactional identities.
Consumer and topic tools
Help verify that record counts match expectations after a fault test, by comparing what was sent with what landed.
The best proof that idempotence works in your setup is a test: send a known number of unique records, deliberately restart a broker or drop network traffic mid-send, and count the records afterward. Any surplus means something in your configuration or application is bypassing the protection.
flowchart TD
A["Alert: rising out-of-order or unknown producer errors"] --> B{"Recent broker restarts or leader changes?"}
B -->|"Yes"| C["Expected in small numbers, check they settle"]
B -->|"No"| D{"Producer count growing fast?"}
D -->|"Yes"| E["Find the client creating many short-lived producers"]
D -->|"No"| F{"Producers idle for longer than expiry?"}
F -->|"Yes"| G["Producers resume after expiry, allow client recovery or shorten idle gaps"]
F -->|"No"| H["Check for unclean elections and lost data on the partition"]
Building a small lab to prove it works
Trust in a guarantee grows fastest when you have watched it survive abuse. A small test environment with three brokers is enough. The plan is simple: run a producer that sends a large, known number of unique records, each carrying a serial number in its value. While it runs, cause trouble: restart the leader of the partition, pause and resume a broker’s process, or add artificial network delay and packet loss between producer and broker. When the run ends, read the partition from the beginning and check two things: that every serial number from the run appears, and that none appears more than once.
Baseline without trouble
Send the records with no faults, and confirm the count matches exactly. This proves your counting method is sound.
Faults with idempotence off
Repeat with the feature deliberately disabled, and observe duplicates or reordering. Seeing the problem first makes the fix meaningful.
Faults with idempotence on
Run the same faults again with the feature enabled, and confirm that duplicates vanish while producer retry metrics show that retries did occur.
Restart the producer mid-run
Kill and restart the producer process partway through and resend the last unconfirmed records. This shows the boundary: duplicates can now appear, and your business-key check should catch them.
After the run, use the log dump tool on the partition’s segment files and look at the producer id, epoch, and base sequence of neighboring batches. Watching consecutive sequence numbers line up, and noticing where a new producer id appears after a restart, turns the theory of this tutorial into something you can point at.
27Design Patterns and Anti-patterns
Habits that repeatedly work well, and habits that repeatedly cause trouble.
Patterns worth copying
One long-lived producer per application instance
Create it at startup, share it across threads, and close it at shutdown. This keeps identity stable and broker state small.
Idempotent producer plus idempotent consumer
Use the producer feature to remove duplicates that come from retries, and add a business key check in the consumer to handle duplicates that originate elsewhere. Two thin layers together cover far more ground than either one alone.
Strong durability settings alongside idempotence
Pair the strongest acknowledgements with a sensible minimum of in-sync replicas and a replication factor of three, so acknowledged data survives failovers and the sequence bookkeeping stays consistent.
Bounded delivery time
Set a delivery time limit that matches your business tolerance, so a stuck send fails visibly rather than retrying for a very long time in silence.
Failure drills
Regularly test with broker restarts and network faults, counting records to confirm no duplicates or losses occurred.
Anti-patterns to avoid
Problem
A web service creates a new producer for each incoming HTTP request, sends one message, and closes the producer.
Why It’s Harmful
Each request triggers an identity request, creates state on the partition leader, and leaves expiring entries behind. Latency grows, brokers spend memory on useless state, and the identity counter and coordinator are stressed.
Correct Approach
Create one producer when the service starts and share it. Producers are thread-safe and designed for exactly this use.
Problem
An application catches a send failure and blindly resends the same business event from its own code, several times, to “make sure” it arrives.
Why It’s Harmful
Each application-level resend is a new record with new sequence numbers. The broker cannot recognize it as a duplicate, so the very duplicates idempotence was meant to prevent are created by the application itself.
Correct Approach
Let the producer’s built-in retries do the work. If a send finally fails, handle it deliberately, for example by writing the event to a durable outbox and sending it with a business key that consumers can use to spot repeats.
Problem
Configuration copied from an old project sets weak acknowledgements and disables retries, while the team believes idempotence is protecting them.
Why It’s Harmful
Idempotence is silently off. Data can be lost on failure, and duplicates can occur if retries are later re-enabled by other means.
Correct Approach
Set idempotence explicitly, use the strongest acknowledgements, keep retries enabled, and verify the effective settings in the startup log.
Problem
A team calls idempotence “exactly once” in design documents and skips consumer-side protection entirely.
Why It’s Harmful
Producer restarts, application resends, and consumer reprocessing all remain possible sources of duplicates. Downstream systems built on this false assumption fail in ways nobody expected.
Correct Approach
Document the exact boundaries of the guarantee, and add business-key deduplication or transactions where end-to-end exactness is truly needed.
28Best Practices and Common Mistakes
A checklist you can carry into design reviews and code reviews.
Best practices
- Keep idempotence enabled, and set it explicitly to catch conflicts
- Use the strongest acknowledgements and a replication factor of three
- Share one long-lived producer per application instance
- Keep retries enabled and bound them with a delivery time limit
- Use a transactional ID when identity across restarts or atomic multi-partition writes are needed
- Add business keys so consumers can detect repeats from any source
- Run fault-injection tests and count the results
Common mistakes
- Creating a producer per request or per small task
- Believing idempotence survives producer restarts
- Resending failed records from application code
- Mixing weak acknowledgement settings with idempotence and not noticing the silent downgrade
- Reusing one transactional ID across different applications
- Ignoring rising producer-state counts on brokers
- Assuming consumers never see repeats
A short design review checklist
Identify the failure you fear
Is it retries within a session, restarts of the application, or reprocessing by consumers? Each needs a different tool.
Choose the layer
Plain idempotence for retries, transactions for identity and atomicity, consumer-side keys for everything else.
Set and verify the configuration
Confirm in logs that idempotence is truly active and that no conflicting setting overrides it.
Test with faults
Restart brokers and drop connections during a send, and confirm that record counts match exactly.
Monitor continuously
Watch retry rates, error types, and producer-state counts so that drift is noticed early.
A tuning cheat sheet for common workloads
Different workloads stress idempotence in different ways. The guidance below is a starting point, not a law, and each row should be confirmed against measurements from your own system.
| Workload | Main concern | Sensible approach |
|---|---|---|
| High-volume logs and metrics | Throughput, cheap duplicates | Keep defaults, use larger batches with a short delay, compress, and rely on idempotence alone |
| Payments and financial events | Correctness above speed | Strongest acknowledgements, replication factor of three, a sensible minimum of in-sync replicas, transactional IDs where needed, and business keys in every event |
| Bursty web traffic | Producer churn and latency | One shared long-lived producer per application instance, never one per request |
| Mobile or edge gateways on flaky links | Frequent retries and long delays | A generous delivery time limit, moderate batch sizes, and monitoring of the retry rate |
| Stream processing jobs | Atomic read-process-write | Transactions with stable transactional IDs, one per task, and read-committed consumers downstream |
| Rarely active batch jobs | State expiry between runs | Expect a fresh identity each run, and let the client handle any expired state automatically |
Reading the retry rate correctly
A nonzero retry rate is not a defect. It means the machinery is doing its job: transient trouble happened and was absorbed. What matters is the trend. A flat, low retry rate with zero final errors is healthy. A retry rate that climbs steadily points to a slowing broker, a saturated network, or an overloaded partition, and the idempotent producer is quietly hiding the symptoms from your application. Treat the retry metric as an early warning light, because it often rises hours before any user-visible failure appears.
Choosing delivery time limits with care
The delivery time limit is the total budget for one batch, including waiting in the buffer, every network attempt, and every retry pause. If it is too short, healthy batches will expire during a brief leader election, forcing epoch bumps and uncertainty for no reason. If it is too long, a genuinely stuck send hangs around for minutes, holding memory and blocking later batches for the same partition. A good starting point is a value comfortably longer than the slowest leader election you have observed, and shorter than the time after which your business would rather see a failure than wait.
Setting the delivery time limit extremely low to “fail fast” causes exactly the unclear outcomes that sequence numbers are meant to avoid. Setting it extremely high hides real outages. Aim for a value that reflects your actual recovery times, and revisit it after each incident.
29Real-World and Industry Examples
The same mechanism shows up in very different businesses, each with its own reasons to care.
Payments and banking events
A payment service publishes “payment captured” events. A retry after a network stall must not create a second event, or downstream ledgers and notifications will double count. Idempotent producers remove the retry duplicates, while a payment reference number in each event lets consumers guard against duplicates from every other source.
E-commerce order pipelines
Order creation, inventory reservation, and shipping messages travel through several topics. Preserved order within a partition, guaranteed even when retries occur, helps ensure that a cancellation never overtakes the order it cancels.
Change data capture streams
Tools that stream database changes into Kafka depend on order and on not repeating changes. Idempotent sending stops a retry from reapplying an update, which keeps downstream replicas consistent with the source.
Metrics and telemetry ingestion
Agents on thousands of servers push counters into Kafka. Duplicate points inflate totals, so removing duplicates at the producer level keeps aggregates trustworthy without expensive deduplication later.
Ride-hailing and logistics tracking
Location updates and trip state changes arrive in bursts, often from mobile networks that drop and reconnect. The backend producers that publish them onward benefit from safe retries and consistent order per trip key.
Stream processing frameworks
Frameworks that offer exactly-once processing rely on transactional producers, which in turn rest on the identity, epoch, and sequence design described here. The guarantees they advertise are only as strong as this foundation.
| Need | Best fit | Why |
|---|---|---|
| Safe retries in a long-running service | Idempotent producer | Cheap, automatic, and covers the common failure |
| Survive application restarts without duplicates | Transactional ID | Stable identity and fencing |
| Write to several topics atomically | Transactions | All-or-nothing commit |
| Protect against user double submissions | Business key and consumer check | Duplicates originate outside Kafka |
| Read-process-write pipelines | Transactions with consumed offsets | Ties input position and output together |
30Frequently Asked Questions
No. Idempotence removes duplicates caused by the producer’s own retries within one producer session and one partition. Exactly-once across restarts, several partitions, and consumers needs transactions and careful consumer design.
Yes. Without a small memory of recent sequence numbers per producer and partition, the broker would have no way of telling a retry from a new batch.
The cluster chooses it. The client asks for an identity, and receives one from a block of unused values held by a broker.
A plain idempotent producer gets a brand new PID and starts every partition’s counter at zero. The old numbers are irrelevant, because the broker treats the new PID as an unrelated producer.
It keeps broker memory small, and it matches the cap of five requests in flight. Any batch that could be retried is always still within the remembered window.
Only slightly, if at all. The extra work is a few integer comparisons and a small header, and the cap of five in-flight requests is rarely a limit in practice.
The producer epoch versions a producer identity and fences older holders of that identity. The leader epoch versions the leadership of a partition and fences older leaders. They serve similar purposes for different actors.
Not at the same time in a healthy cluster. For plain idempotent producers, each instance gets its own PID. For transactional producers, two instances with the same transactional ID share a PID, but only one, the one with the higher epoch, is allowed to write.
It can be rebuilt from the batch headers in the log, using the last snapshot as a shortcut. Followers hold their own copies too, so a leader change does not lose the state.
The broker discards state for producers that have been silent longer than the expiration period. When the producer wrote again, the broker no longer recognized its identity. Recent clients recover by asking for a new epoch.
Usually not. In current client versions it is enabled by default. You mainly need to avoid configuration settings that conflict with it, and confirm the effective settings at startup.
No. Deduplication looks at the batch header fields, not the compressed record contents, so it works the same with any compression choice.
So that every acknowledged batch is present on all in-sync replicas. Then a new leader after failover already knows the sequence numbers, and retries are handled correctly.
Yes, through the log dump tool, which prints batch headers from segment files. Regular consumers do not receive these fields as part of the records they read.
No. Such a job also needs its consumed positions tied to its produced output. Transactions do this, by committing the output and the input offsets together as one unit.
An older instance of a transactional producer that is still running after a newer instance has taken over, perhaps after a long pause. Its lower epoch means brokers refuse its writes, so it cannot cause harm.
Yes, and it is the simplest case. The producer keeps one counter for that partition, and the leader keeps one notebook entry per producer. Everything works exactly as described, just with fewer counters.
Existing counters continue unchanged, and the producer starts fresh counters for any new partitions it writes to. The guarantee stays intact, although the mapping of keys to partitions may change, which affects ordering by key rather than deduplication.
No. A batch is accepted or treated as a repeat as a whole. The broker compares the full range of sequence numbers claimed by the batch, so there is no state in which half of a batch is stored and half is recognized as a repeat.
Indirectly. The state itself is not copied as a separate object. Instead, each follower rebuilds the same state from the batches it copies, because the batch headers carry all the needed numbers. Snapshots are local to each replica.
A few are normal while leadership moves and producers retry. If they continue for a long time, check for lost data on the partition, unclean leader elections, or producers whose state expired while they were idle.
Not in the record objects they receive. The values live in the batch header and are used by brokers, and by tools that read log files directly. Consumers only see the effect: no repeated retries in the data.
It reduces memory, but makes it more likely that a producer returning after a quiet gap finds its entry gone. Recent clients recover, yet each recovery involves an epoch bump and some uncertainty about the last unconfirmed batch. Shorten it only if your producers write frequently and your memory truly requires it.
For anything important, yes. Idempotent producers close the retry gap, but duplicates can still arrive from application resends, producer restarts, or consumer reprocessing. A business key check downstream is a cheap and durable second line of defense.
31Summary and Key Takeaways
The idempotent producer solves a specific, common problem: a retry that follows a lost acknowledgement must not leave a second copy in the log. It does so with three simple ingredients. A producer ID identifies who is writing, an epoch says which version of that identity is valid, and per-partition sequence numbers, carried in the header of every batch, say exactly which records a batch contains. The partition leader keeps a small notebook of recent numbers for each producer, rebuilds it from the log after restarts, and shares it with followers through ordinary replication, so failovers keep the protection. Understanding the precise edges of this guarantee lets you use it confidently, and add transactions or consumer-side keys where a larger promise is needed.
Key Takeaways
- Identity plus numbering — the PID names the producer and the base sequence numbers the records, so the broker can tell a repeat from a new batch by reading the header.
- Broker-side detection — the partition leader keeps recent sequence details for each producer, up to five batches, and skips repeats while returning the original offsets.
- Gaps are rejected — a batch that skips ahead is refused, which preserves both completeness and order within the partition.
- Epochs fence the past — a higher epoch always wins, so stale or zombie producers are blocked, and clients can bump the epoch to recover from lost state.
- State survives failure — headers on disk, snapshots, and replication let restarts and leader changes keep the notebook, provided the strongest acknowledgements are used.
- Boundaries are real — the guarantee covers one producer session and one partition. Restarts, application resends, multi-partition atomicity, and consumer reprocessing need transactions or business-level deduplication.
- Keep producers long-lived — share one producer per application instance, verify settings at startup, monitor retries and producer-state growth, and test with injected faults.