Tutorial

Kafka Delivery Guarantees: At-Most-Once, At-Least-Once, and Exactly-Once

At-most-once, at-least-once, and exactly-once explained, with the producer, consumer, and transaction settings that decide which one you get.

·
6 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

Kafka Delivery Guarantees: At-Most-Once, At-Least-Once, and Exactly-Once

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

acks=0, or no retries

Commit offsets before processing

Records can be lost, but never repeated

At least once

acks=all with retries

Commit offsets after processing

Nothing is lost, but records can repeat

Exactly once

Idempotence plus transactions

read_committed, with offsets committed in the transaction

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_committed so 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

acks

How many replicas confirm a write

all

enable.idempotence

Prevents duplicates and reordering from producer retries

true

transactional.id

Identifies a producer so it can use transactions

None (set it to enable transactions)

transaction.timeout.ms

How long a transaction may stay open before it is aborted

1 minute

isolation.level (consumer)

Whether consumers see uncommitted transactional records

read_uncommitted

enable.auto.commit (consumer)

Commit offsets automatically on a timer

true

processing.guarantee (Kafka Streams)

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_committed on consumers. Without it, they read aborted and in-flight records and see duplicates.

  • Sharing one transactional.id across 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_committed consumer 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!

Samod Alex

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Samod Alex's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.