Guaranteeing One‑Time Delivery in Kafka: How Exactly‑Once Semantics Work in Practice
Duplicate messages can break event‑driven systems. This post explains Kafka’s Exactly‑Once Semantics, shows how to enable it with a producer and consumer, and discusses the trade‑offs you’ll face when turning EOS into production.
13 Jul 2026, 10:23 UTC

The Duplicate Message Problem
In a typical event‑driven architecture, a producer writes a record to a Kafka topic and a consumer processes it. If the producer retries after a transient failure, or if the consumer re‑reads a partition after a crash, the same record can be processed twice. The result is duplicate updates, inconsistent state, or double‑charged invoices. Traditional “at‑least‑once” delivery guarantees cannot prevent this without additional idempotence logic in the consumer.
Exactly‑Once Semantics (EOS) in a Nutshell
EOS is Kafka’s built‑in mechanism for eliminating duplicates. It relies on two pieces of configuration:
- Transactional producer – the producer groups records into a transaction that is either committed or aborted.
- Consumer isolation level – the consumer reads only records from committed transactions.
When both sides are configured correctly, every record that reaches a consumer has been committed exactly once, regardless of retries or re‑balances.
How the Transaction Flow Works
- The producer calls
initTransactions()and starts a transaction withbeginTransaction(). - It sends one or more records to one or more partitions.
- It either calls
commitTransaction()orabortTransaction(). - The broker writes a special
Transactionalmarker to the log. Only after the commit marker is visible to all in‑sync replicas does the transaction become visible. - A consumer with
isolation.level=read_committedwill skip any records from aborted transactions and read only committed ones.
Configuring EOS in a Real Project
Producer Settings
Below is a minimal Java‑style configuration. Replace placeholders with your environment values.
Properties props = new Properties();
props.put("bootstrap.servers", "");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("enable.idempotence", "true"); // Guarantees idempotent writes
props.put("transactional.id", "user-updates-producer"); // Unique per producer instance
props.put("transaction.timeout.ms", "60000"); // 60‑second transaction timeout
After creating the KafkaProducer, call initTransactions() once before sending data. For each batch:
producer.beginTransaction();
producer.send(new ProducerRecord<>("user-updates", key, value));
// … send more records if needed
producer.commitTransaction();
Consumer Settings
The consumer must be configured to read only committed data.
Properties consumerProps = new Properties();
consumerProps.put("bootstrap.servers", "");
consumerProps.put("group.id", "user-updates-consumer-group");
consumerProps.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumerProps.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumerProps.put("isolation.level", "read_committed"); // Skip uncommitted records
consumerProps.put("enable.auto.commit", "false");
After processing a batch, commit offsets manually:
consumer.commitSync();
Verification Steps
- Run
kafka-topics.sh --versionto confirm broker version ≥ 0.11. - Send a batch with
commitTransaction()and verify that a consumer withread_committedreads each record exactly once. - Use
kafka-consumer-groups.sh --describe --group user-updates-consumer-groupto check that offsets are updated only after the transaction commit. - Measure latency before and after enabling EOS to quantify the overhead.
Trade‑offs and When to Use EOS
- Latency & Throughput – The transactional log adds metadata and requires replication of commit markers. A large transaction (many records) can increase round‑trip time and CPU usage on the broker.
- Resource Usage – EOS requires the broker to maintain a transaction state log. Set
transaction.state.log.min.israppropriately to balance durability and performance. - Compatibility – Clients older than 0.11 cannot use transactional IDs. If you need to support legacy producers, keep EOS optional.
- Complexity – You must handle transaction timeouts and retries. A mis‑configured consumer isolation level (e.g.,
read_uncommitted) negates the benefit.
Actionable Checklist for Production Roll‑out
- Confirm broker version and enable
allow.auto.create.topicsif you plan to auto‑create transactional topics. - Deploy a test producer/consumer pair with EOS enabled and run a load test that includes retries.
- Measure the impact on latency and throughput; adjust
transaction.timeout.msandtransaction.state.log.min.isras needed. - Update deployment scripts to include
enable.idempotence=trueandtransactional.idfor all producers that require EOS. - Document the required consumer configuration (
isolation.level=read_committed) and enforce it via code reviews. - Monitor broker metrics such as
transaction_commit_latencyandtransaction_abort_rateto detect issues early.
Once the above steps are in place, you can confidently rely on Kafka’s Exactly‑Once Semantics to eliminate duplicate processing, simplify consumer logic, and build more resilient event‑driven systems.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.