
Part 5 of Apache Kafka from Zero to Production.
Can Kafka lose a message, or deliver it twice? It can do either, or neither, depending on how you configure your producers and consumers. Kafka does not choose a delivery guarantee for you. Parts 2 and 3 introduced the settings, and this post puts them together into the three guarantees you can build.
The three guarantees
Guarantee | Producer side | Consumer side | What can go wrong |
|---|---|---|---|
At most once |
| Commit offsets before processing | Records can be lost, but never repeated |
At least once |
| Commit offsets after processing | Nothing is lost, but records can repeat |
Exactly once | Idempotence plus transactions |
| Each result appears once in Kafka, with limits covered below |
Each guarantee is the combination of choices on both sides of the log. A reliable producer feeding a consumer that commits too early still loses data.
Where messages go wrong
A record travels in two hops, producer to broker and broker to consumer, and a failure at either one forces a choice between losing the record and repeating it.
The acknowledgement is lost. The producer sent a record and the broker stored it, but the reply never arrived. If the producer retries, the record is written twice. If it does not retry, a real failure would lose the record.
The leader fails before replication. With
acks=1, a record acknowledged by the leader alone can vanish when that leader dies.The consumer commits too early. It commits an offset, then crashes before finishing the work. On restart, the record is skipped.
The consumer commits too late. It finishes the work, then crashes before committing. On restart, the record is processed again.
At most once
On the producer side, this means acks=0 or disabling retries. On the consumer side, it means committing the offset before processing the record. If anything fails in between, that record is gone.
This fits data where an occasional gap does not matter and repeats would be worse, such as high-volume metrics or sampled telemetry. For most business data it is the wrong choice.
At least once
This is the practical default. Producers use acks=all with retries (both are on by default in current Kafka versions), and consumers commit only after processing, as in the loop from Part 3. Nothing acknowledged is lost, but a crash or rebalance can cause some records to be delivered again.
The standard way to live with duplicates is to make processing idempotent, meaning that running it twice has the same effect as running it once. A few common techniques:
Write with an upsert keyed on a unique ID carried in the event, so a repeated write overwrites itself.
Keep a record of processed event IDs and skip any that appear again.
Design operations so repeating them changes nothing, such as setting a status instead of incrementing a counter.
At-least-once with idempotent handlers is what most Kafka applications should use.
Exactly once: idempotence plus transactions
Part 2 showed that the idempotent producer removes duplicates caused by retries, but only within one producer session. Transactions extend this to a whole unit of work. In a consume-transform-produce loop, a transaction makes three things happen together or not at all: the output records are written, possibly to several partitions and topics, and the input offsets are committed.
It works through a few pieces:
A transactional ID (
transactional.id) identifies the producer across restarts. A broker-side transaction coordinator tracks its transactions in an internal topic.When a new instance starts with the same ID, the older one is fenced and can no longer write, which stops zombie instances from corrupting results.
On commit or abort, the transaction coordinator writes a marker to each partition involved.
Consumers set
isolation.level=read_committedso they skip records from aborted transactions and wait for open ones to finish. By default, consumers read everything, including uncommitted records.
Here is the core of a transactional loop in Java, assuming a consumer with auto-commit off and read_committed, and a producer created with a transactional.id:
producer.initTransactions();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
if (records.isEmpty()) continue;
producer.beginTransaction();
try {
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
for (ConsumerRecord<String, String> record : records) {
producer.send(new ProducerRecord<>("orders-enriched", record.key(), enrich(record.value())));
offsets.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1));
}
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
producer.commitTransaction();
} catch (ProducerFencedException e) {
producer.close(); // a newer instance with the same transactional.id took over
throw e;
} catch (KafkaException e) {
producer.abortTransaction(); // outputs and offsets roll back together
// rewind the consumer to its last committed offsets so the batch is read again
}
}The important line is sendOffsetsToTransaction. Because the consumer's progress is committed inside the same transaction as the output, a crash can never leave results written but offsets uncommitted, or the reverse.
If you use Kafka Streams, you get all of this by setting processing.guarantee=exactly_once_v2, without writing the loop yourself.
Transactions are not free. They add some latency and coordination work, and a long-running open transaction holds back read_committed consumers at the last stable offset, the position before the first unfinished transaction. Using one transaction per batch of records, rather than per record, keeps the overhead small.
Where exactly once stops
Kafka's exactly-once guarantee covers reading from Kafka and writing back to Kafka. It cannot reach outside the cluster. If your processing sends an email, calls an API, or writes to a database, Kafka cannot undo or deduplicate those effects when a transaction aborts and the batch runs again.
For those cases, combine at-least-once delivery with one of these approaches:
Make the external write idempotent, using a unique event ID as the key.
Store the consumer's offsets in the same database transaction as the results, and resume from the database's offsets on restart.
Use an outbox table to publish events from a database change reliably.
When people say Kafka offers exactly-once processing, what they mean is exactly-once effects on Kafka topics. Past the edge of the cluster, idempotence is your tool.
Choosing a guarantee
Metrics, logs, and telemetry where gaps are tolerable: at most once, or at least once if the cost is low.
Most business events: at least once with idempotent handlers.
Pipelines from Kafka to Kafka with correctness requirements, such as financial calculations or aggregations: transactions or Kafka Streams with exactly-once.
Settings at a glance
Setting | What it controls | Default |
|---|---|---|
| How many replicas confirm a write | all |
| Prevents duplicates and reordering from producer retries | true |
| Identifies a producer so it can use transactions | None (set it to enable transactions) |
| How long a transaction may stay open before it is aborted | 1 minute |
| Whether consumers see uncommitted transactional records | read_uncommitted |
| Commit offsets automatically on a timer | true |
| Streams delivery guarantee | at_least_once |
Mistakes to avoid
Assuming the defaults give exactly-once. They give at-least-once on the producer side, and consumers still need commit and processing logic that handles repeats.
Forgetting
read_committedon consumers. Without it, they read aborted and in-flight records and see duplicates.Sharing one
transactional.idacross running instances. They fence each other. Give every producer instance its own stable ID.Expecting exactly-once to cover databases and APIs. It stops at the Kafka boundary.
Keeping transactions open for a long time. They delay every
read_committedconsumer behind them.
Next in the series
This is Part 5 of Apache Kafka from Zero to Production. In Part 6, we look at Kafka Connect and Kafka Streams: moving data in and out of Kafka without custom code, and processing it as it flows.
Frequently Asked Questions
What are the three Kafka delivery guarantees and how do they differ?
At‑most‑once may lose records but never repeats (producer acks=0 or no retries, consumer commits offsets before processing). At‑least‑once never loses records but may repeat them (producer acks=all with retries, consumer commits after processing). Exactly‑once ensures each record appears once in Kafka by using idempotent producers and transactions together with read_committed consumers.
How does a producer achieve at‑least‑once delivery?
By setting acks=all and enabling retries (both on by default), the producer only considers a record written when all in‑sync replicas acknowledge it, and it retries on failure, guaranteeing no loss.
What consumer setting is required to read only committed records for exactly‑once processing?
Set isolation.level=read_committed so the consumer skips records from aborted transactions and sees only fully committed data.
How do Kafka transactions provide exactly‑once semantics?
A transaction groups output writes and offset commits into a single atomic unit; on commit, both the produced records and the consumer offsets become visible together, preventing partial results or duplicate processing.
Does Kafka's exactly‑once guarantee prevent duplicate effects outside the cluster, such as sending emails or writing to a database?
No; exactly‑once only covers Kafka topics. External side effects must be made idempotent or handled with other techniques because Kafka cannot roll them back.
What are common ways to handle duplicate records when using at‑least‑once delivery?
Make processing idempotent, for example by upserting with a unique key, tracking processed event IDs to skip repeats, or designing operations that are safe to repeat (e.g., setting a status instead of incrementing a counter).
What are typical mistakes that prevent achieving the intended delivery guarantee?
Assuming defaults give exactly‑once, forgetting to set read_committed, sharing a transactional.id across instances (causing fencing), expecting exactly‑once to cover external systems, and keeping transactions open too long, which delays read_committed consumers.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!