Kafka delivery semantics
Article.
“What delivery guarantee does Kafka give?” is a trap. Kafka gives you whichever you configure, producer and consumer contribute independently, and the honest answer names the crash window that each choice leaves open.
Almost every real system runs at-least-once with idempotent processing. Exactly-once is a real Kafka feature with a specific scope, and knowing where that scope ends is the actual question.
The mechanism: semantics are crash windows
Every guarantee is decided by what happens if the process dies at one particular instant. Name the instant and the semantics follow.
The consumer window
at-most-once at-least-once
poll poll
commit offset process ← side effects happen
X crash X crash
process (never runs) commit (never runs)
-> message lost -> message redeliveredThat is the whole consumer story. Commit before processing loses; commit after processing duplicates. There is no third ordering, which is why at-least-once plus idempotent handlers is the standard answer rather than a compromise.
The producer window
producer -> broker: append succeeded
broker -> producer: ack LOST in the network
producer: retry
broker: appends the SAME message againThis is why at-least-once produces duplicates even when your code sends once — the duplicate is created by a retry of a write that already succeeded, and the producer has no way to know.
The idempotent producer closes exactly this window. Each producer gets a PID, each message a sequence number per partition, and the broker drops a re-send whose sequence it has already appended.
producer = Producer({
"bootstrap.servers": "broker:9092",
"acks": "all", # all in-sync replicas
"enable.idempotence": True, # Java client default since 3.0; librdkafka defaults to False
})Its scope is narrow and worth stating precisely: broker-side retries within
one producer session, per partition. Not two produce() calls from your code,
not across a restart. It is not application-level deduplication.
acks |
Durability |
|---|---|
0 |
fire and forget; loss on any hiccup |
1 |
leader only; lost if the leader dies first |
all |
all in-sync replicas; pair with min.insync.replicas=2 |
Transactions, and where exactly-once stops
Transactions make writes to several partitions and the offset commit atomic:
producer = Producer({"transactional.id": "order-enricher-1"})
producer.init_transactions()
while True:
msgs = consumer.consume(100, timeout=1.0)
producer.begin_transaction()
for m in msgs:
producer.produce("enriched-orders", transform(m.value()))
producer.send_offsets_to_transaction( # offsets inside the txn
consumer.position(consumer.assignment()),
consumer.consumer_group_metadata())
producer.commit_transaction()Two mechanics carry it:
transactional.idmust be stable across restarts. On restart the broker bumps the producer epoch and fences the zombie — an old instance still running cannot commit. That fencing is what makes the guarantee survive a crash rather than a clean shutdown.- Downstream consumers need
isolation.level=read_committed, or they read aborted writes and the guarantee evaporates at the reader.
Where the guarantee stops
The scope is Kafka-to-Kafka. The unit made atomic is consume, transform, produce. The moment your consumer writes to Postgres, charges a card or sends an email, the transaction does not reach there:
[topic] --txn--> [topic] exactly-once holds
[topic] -------> [Postgres] it does notEnd to end you are back to idempotent writes, natural keys, upserts, or an idempotency key — see Idempotency keys.
“Exactly-once delivery” does not exist over arbitrary side effects. Exactly-once processing is engineered with deduplication, and saying either “yes, everywhere” or “no, it’s marketing” is wrong.
The consumer side
consumer = Consumer({
"group.id": "billing",
"enable.auto.commit": False,
"auto.offset.reset": "earliest", # only when no committed offset exists
})
consumer.subscribe(["orders"])
while True:
msg = consumer.poll(1.0)
if msg is None or msg.error():
continue
handle(msg) # side effects first
consumer.commit(msg, asynchronous=False) # then commitenable.auto.commit=true commits on a timer from the polling thread, which
means offsets can be committed for messages you have not finished — silent loss
— or the timer can fire long after side effects, giving duplicates. You get
vague semantics, which is the reason serious consumers commit manually.
auto.offset.reset applies only when the group has no committed offset, and
it is a classic incident: a new group or expired offsets plus latest silently
skips everything already in the topic.
Where it breaks in production
The rebalance storm
The failure that takes a consumer group down and looks like nothing is wrong.
A consumer must call poll() at least every max.poll.interval.ms — five
minutes by default. If a handler is slow, the coordinator decides the consumer
is dead and triggers a rebalance:
consumer A: handling a batch, 6 minutes
coordinator: A is dead -> rebalance
consumer B: takes A's partitions, reprocesses the batch
also takes 6 minutes -> rebalance
...The group spends its time rebalancing rather than consuming, lag grows without bound, and every partition’s work is reprocessed repeatedly. The symptom is lag climbing while consumers look healthy and CPU is idle.
Fixes, in order: process smaller batches (max.poll.records), move slow work
off the poll loop, or raise max.poll.interval.ms deliberately — the last one
is a decision about how long a stuck consumer stays undetected, not a free knob.
Committing an offset commits everything before it
Offsets are per partition, not per message. Committing offset 105 asserts everything up to 105 is done.
in flight: 101 102 103 104 105
ok ok FAIL ok ok
commit(105) -> 103 is now permanently skippedAny handler that processes out of order, or continues past a failure, must not commit past the failure. Either stop at the first failure, or route the failed message to a dead-letter topic before committing — never leave the gap silent.
One transactional.id shared by instances
Two instances with the same id fence each other continuously: each restart bumps
the epoch and invalidates the other. It presents as constant
ProducerFenced errors and no progress. The id must be stable and unique per
logical producer.
Testing on the happy path
Duplicates appear on retries, rebalances and crashes. A test that produces a hundred messages and reads a hundred back proves nothing about your semantics. Test by killing the consumer mid-batch and asserting the sink is correct.
Choosing
| Building | Configure |
|---|---|
| Metrics, loss tolerable | acks=1, auto-commit |
| Business events | acks=all, idempotent producer, manual commit |
| Kafka to Kafka | transactions plus read_committed |
| Kafka to a database | at-least-once plus dedupe at the sink |
The trade not taken
Exactly-once is expensive and it is usually the wrong thing to reach for. Transactions add coordination, latency and operational complexity, and they only cover the Kafka-to-Kafka case that many pipelines are not.
At-least-once with an idempotent sink is simpler, faster, and covers everything — including the external systems transactions cannot reach. If a write is an upsert on a natural key, duplicates are free and you have bought nothing by preventing them.
The question to ask before configuring transactions is what a duplicate would actually cost. If the answer is “nothing, the upsert handles it”, stop there.
Related
Interview angle 6
- “What delivery semantics does Kafka give you?” — whichever you configure.
The producer contributes with
acksand idempotence, the consumer with when it commits relative to processing. The useful answer names the crash window each choice leaves open.- Follow-up: “So which do you actually run?” — at-least-once with an idempotent handler. Commit before processing loses messages, commit after duplicates them, and there is no third ordering.
- “How does the idempotent producer work?” — a producer id plus a
per-partition sequence number, so the broker drops a re-send it has already
appended. It closes the window where a write succeeded and the ack was lost.
- Follow-up: “What does it not cover?” — anything outside one producer
session: two
produce()calls from your code, or a restart. It is not application-level deduplication.
- Follow-up: “What does it not cover?” — anything outside one producer
session: two
- “Is Kafka exactly-once real?” — yes, Kafka-to-Kafka, via transactions,
stable
transactional.id, zombie fencing on epoch bump, andread_committeddownstream. It stops at the first external system; writing to Postgres or calling an API is outside the transaction. - “Your consumer group’s lag is climbing but the consumers look healthy.” — a
handler exceeding
max.poll.interval.ms, so the coordinator declares it dead and rebalances, the next consumer inherits the same slow batch, and the group rebalances instead of consuming. Smaller batches, or move slow work off the poll loop. - “Message 103 failed but 104 and 105 succeeded. What do you commit?” — nothing past 103. An offset commit asserts everything before it is done, so committing 105 permanently skips 103. Stop at the failure or dead-letter it first.
- “When would you not bother with exactly-once?” — when a duplicate costs nothing, which is most of the time. An upsert on a natural key makes duplicates harmless, and at-least-once with an idempotent sink is simpler and covers the external systems transactions cannot.