Exactly-once message processing has long been the “holy grail” of distributed systems. For decades, developers were forced to choose between losing messages (at-most-once) or handling duplicates (at-least-once). Kafka’s breakthrough implementation in 2017 changed this landscape by moving exactly-once complexity into the messaging infrastructure itself.
This article explains how exactly-once semantics actually work under the hood, from the perspective of each component in the system, giving you the technical understanding to implement similar guarantees in your own distributed systems.
The Core Challenge: Why Exactly-Once is Hard
The fundamental problem stems from the distributed nature of message systems. Consider this sequence:
- Producer sends message to broker
- Broker successfully stores message
- Network fails before broker can acknowledge
- Producer assumes failure and retries
- Result: duplicate message
Traditional solutions pushed this complexity to applications:
- Manual deduplication: Track processed message IDs in databases
- Idempotent operations: Design business logic to handle duplicates safely
- Two-phase commit: Coordinate transactions across systems (expensive and fragile)
The innovation in modern systems like Kafka is recognizing that this problem is better solved at the infrastructure level with predictable, reusable techniques.
The Producer Side: Generating Unique, Sequenced Messages
From the producer’s perspective, exactly-once semantics are achieved through two complementary mechanisms: idempotent publishing for single-partition operations and transactional publishing for multi-partition operations.
Producer Idempotency: Sequence Numbers per Partition
Every producer gets assigned a unique Producer ID (PID) when it starts up. This isn’t just a random number — it’s managed by the broker and tied to the producer’s lifecycle.
Along with each message batch, the producer includes a sequence number that starts at 0 and increments monotonically for each partition:
Producer ID: 12345
Partition 0: [seq=0] [seq=1] [seq=2] [seq=3] ...
Partition 1: [seq=0] [seq=1] [seq=2] [seq=3] ...
Sequence numbers are assigned when the batch is created on the producer side, not when it’s sent. This means that even if a producer retries sending a batch multiple times due to network timeouts, the sequence numbers remain constant.
This design handles the most common failure scenarios:
- Network retries: Same sequence number sent multiple times
- Producer restarts: New PID prevents confusion with old sequences
- Partial broker failures: Sequence tracking survives most broker restarts
Producer Sessions: Handling Crashes and Zombies
The Producer ID system is more sophisticated than it initially appears. When a producer crashes and restarts, you could have a “zombie” producer that continues sending messages after a new instance has started.
This is solved through epoch numbers — a separate 16-bit counter (distinct from the PID) that increments each time a producer with the same transactional.id (a user-configured string identifier that represents the logical identity of a service, like "payment-service" for all pods in a K8s deployment) initializes:
Session 1: PID=12345, Epoch=0 (starts sending messages)
↓ (producer crashes)
Session 2: PID=12345, Epoch=1 (new instance starts)
↓ (messages from Session 1 are now rejected)
When a producer has a transactional.id, the PID stays the same across restarts (enabling sequence tracking continuity), but the epoch increments (enabling zombie fencing). Without transactional.id, each restart gets a completely new PID and loses idempotency guarantees.
Fencing mechanism: Higher epoch numbers automatically “fence out” previous instances. This prevents zombie producers from corrupting the message stream by ensuring that only the latest producer session can successfully send messages — any delayed or duplicate messages from old sessions are rejected, maintaining exactly-once guarantees even across producer crashes and restarts.
Transactional Publishing: Multi-Partition Atomicity
For operations that span multiple partitions or topics, producers use transactional APIs. The producer coordinates with a Transaction Coordinator to ensure atomic commits across all involved partitions. Crucially, transactions make use of exactly once guarantees in individual partitions to provide multi partition atomicity and exactly one guarantees. It does it by using distributed transactions, but limited to Kafka brokers within a single cluster — it doesn’t span external systems like databases.
The transaction flow from producer’s perspective:
- Begin transaction: Get transaction ID from coordinator
- Send messages: to multiple partitions/topics (all marked as part of this transaction) — they are guaranteed to be sent exactly once
- Commit transaction: Tell coordinator to atomically commit across all partitions
- Handle coordinator response: Success means all partitions committed, failure means all aborted
Transactions can span completely different topics, not just partitions within a single topic. This enables complex multi-service operations to be atomic.
The Broker Side: Validation, Coordination, and Storage
From the broker’s perspective, exactly-once semantics require maintaining additional state and implementing sophisticated validation logic.
Sequence Number Validation
Each broker maintains an in-memory map of the last committed sequence number for each active PID/partition combination. When a message batch arrives:
- If sequence < expected: Duplicate — discard silently
- If sequence = expected: Also duplicate — already processed
- If sequence > expected: Gap detected — reject with error
- If sequence = expected + 1: Process normally and increment
Persistence for crash recovery: Sequence numbers are persisted periodically in .snapshot files. When a broker crashes and restarts, it rebuilds the sequence tracking map by reading snapshots and replaying log segments.
Memory management: The broker only tracks sequences for active producers. Old PID/partition mappings are cleaned up after producer sessions expire.
Transaction Coordination
The Transaction Coordinator is a special broker component that manages the lifecycle of multi-partition transactions. It maintains transaction state in a replicated internal topic called __transaction_state.
Transaction state progression:
Empty → Ongoing → PrepareCommit → CompleteCommit
↓
PrepareAbort → CompleteAbort
Two-phase commit implementation:
- Phase 1 (Prepare): Coordinator writes “PrepareCommit” to transaction log
- Phase 2 (Commit): Coordinator writes commit markers to all participating partitions
- Completion: Coordinator writes “CompleteCommit” to transaction log
The coordinator must handle the case where a transaction coordinator itself crashes mid-transaction. The replicated transaction log allows recovery coordinators to complete or abort in-flight transactions.
Transaction Deduplication
Here’s where the broker implements the crucial insight that atomicity alone doesn’t provide exactly-once. Even if a transaction commits atomically across all partitions, the producer might not receive the success confirmation and retry the entire transaction.
The deduplication check: When a producer attempts to commit a transaction, the coordinator checks:
- Has this producer session (PID + Epoch) already committed a transaction with this sequence?
- If yes: Return success without re-executing (idempotency)
- If no: Proceed with normal two-phase commit (atomicity)
This combination of transaction-level idempotency with atomic commits is what delivers true exactly-once semantics.
Control Records and Consumer Isolation
For consumers configured with isolation.level=read_committed, brokers implement additional logic:
Last Stable Offset (LSO): Consumers can only read up to the offset of the first message belonging to an open (uncommitted) transaction. This prevents reading messages that might later be aborted.
Control record filtering: Brokers filter out aborted transactional messages before sending them to consumers. Consumers never see messages from aborted transactions.
Transaction markers: Special control records indicate transaction boundaries (commit/abort) but aren’t exposed as regular messages to applications.
The Consumer Side: Processing Messages Exactly Once
From the consumer’s perspective, exactly-once semantics aren’t just about message delivery — they’re about ensuring that message processing and state updates happen atomically.
The Consumer Challenge
Even with exactly-once delivery from the broker, consumers face their own exactly-once challenge:
- Receive and process messages
- Update application state (database, cache, etc.)
- Commit consumer offsets
The failure scenario: If the consumer crashes after step 2 but before step 3, it will reprocess the same messages without knowing they were already handled.
Consumer Processing
Kafka provides exactly-once semantics end-to-end (producer → broker → consumer) when properly configured. However, consumers can still see duplicate messages if they don’t manage offsets correctly — this is a configuration and usage issue, not a limitation of Kafka’s exactly-once guarantees. We’ll expand on it below.
How exactly-once delivery works from the consumer side:
- Consumers with
isolation.level=read_committedonly see messages from committed transactions - The broker tracks consumer offsets and only delivers messages that haven’t been acknowledged yet
- When offsets are committed (either manually or automatically), those messages won’t be delivered again
- If a consumer crashes before committing offsets, it will receive the same messages again
You might already be questioning: what if the application dies after committing business changes (e.g., withdrawing from an account) but before acking the Kafka message? We might get the worst outcome: the business change happened, but Kafka will redeliver the message (upon app restart), potentially causing duplicate processing (double withdrawal).
Even with such exactly-once delivery, there’s still a gap between processing messages and committing offsets that can cause duplicate processing effects. Several solutions to this challenge are possible:
- Kafka transactional approach: Instead of performing business changes in the code directly, we can do it asynchronously: send a message to another component that will perform that non-idempotent business change. We can use Kafka transactions to coordinate offset commits (confirming that the message is read) with the production of that business message. That would make everything atomic within Kafka
- Manual offset management: Store consumer offsets in the same system as your business state (e.g., database) and commit them together using external transactions
- Idempotency approach: Design your processing logic to handle duplicate messages safely, making the system resilient to reprocessing regardless of offset timing
Performance and Operational Considerations
Exactly-once semantics aren’t free. Here’s what you’re actually paying for these guarantees:
The throughput hit: Idempotent producers add a modest 2–5% overhead (mainly from sequence number bookkeeping), but full transactions can slow things down by 10–20%. That coordination and two-phase commit process takes time.
Memory costs: Brokers need to track sequence numbers for every active producer/partition combo, though this is usually pretty small. The bigger concern is consumers having to buffer messages from ongoing transactions — if you have long-running transactions, this can eat up memory quickly.
Configuration gotchas: You can’t just flip a switch and get exactly-once. Producer idempotency requires keeping max.in.flight.requests at 5 or below to maintain message ordering. Transaction timeouts are tricky to tune - too short and legitimate operations get aborted, too long and failed producers block consumers. And you must use acks=all or your durability guarantees fall apart.
The bottom line: exactly-once semantics add complexity and overhead, but for systems where duplicates or losses create real business problems, it’s usually worth it.
How Other Messaging Systems Handle This Problem
Kafka isn’t the only game in town, but it’s definitely ahead of the pack when it comes to exactly-once semantics. Here’s how the competition stacks up:
Many modern systems also support Exactly Once:
Apache Pulsar takes a simpler approach — automatic broker-level deduplication using message IDs. It’s less sophisticated than Kafka’s system but much easier to use. You get basic exactly-once without much configuration headache.
NATS JetStream offers flexible deduplication windows that you can tune, including “infinite deduplication” if you never want to see the same message twice. It’s powerful but requires more thoughtful configuration.
Amazon Kinesis is probably the closest competitor to Kafka, but it stops short of true exactly-once semantics. It has sequence numbers per shard (similar to Kafka’s per-partition approach) and provides at-least-once delivery, but you still need to implement your own deduplication logic in applications.
Amazon SQS FIFO provides deduplication using message deduplication IDs — you either provide your own ID or AWS generates one based on message content. But there’s a catch: it only works within 5-minute windows and is limited to single queues. Fine for simple use cases, but not for complex distributed operations.
Amazon SNS FIFO works similarly to SQS FIFO — it uses deduplication IDs to prevent duplicate publishing within 5-minute windows. However, this only prevents duplicate messages from being published to the topic; it doesn’t provide cross-topic atomicity like Kafka transactions do.
The systems that punt on the problem:
RabbitMQ and ActiveMQ basically throw up their hands and say “figure it out yourself.” They provide at-least-once delivery and expect you to handle deduplication in your application code. It’s the old-school approach that pushes all the complexity to you.
The takeaway: Most systems either provide basic deduplication OR atomic operations, but not both. Kafka’s innovation was recognizing that you need both idempotency and atomicity working together to truly solve the exactly-once problem.
When Should You Actually Use it
Exactly-once semantics are powerful but not always worth the complexity. Let’s break down when we absolutely need them versus when we’re probably overthinking things.
We definitely need exactly-once when:
Money is involved — Payment processing, trading systems, accounting — basically anything where duplicate transactions mean someone loses real money or regulatory agencies start asking uncomfortable questions.
Inventory matters — E-commerce stock levels, supply chain management, anything where double-processing means you oversell products and have angry customers calling your support team.
Compliance is watching — Healthcare records, financial reporting, audit systems — when regulators require data accuracy and “oops, we processed that twice” isn’t an acceptable explanation.
We can probably skip it when:
Analytics and metrics — Processing millions of page views where being off by 0.1% doesn’t change business decisions. The performance hit usually isn’t worth perfect accuracy for this stuff.
Operational monitoring — System logs, performance metrics, health checks — you care more about low latency than perfect counts. Missing the occasional data point is better than slowing down your entire monitoring pipeline.
Non-critical notifications — Email alerts, Slack messages, push notifications — if the worst case scenario is someone gets a duplicate notification, the complexity probably isn’t justified.
Key Takeaways
- Exactly-once requires both idempotency and atomicity — neither mechanism alone is sufficient
- Producer sequence numbers provide the foundation for eliminating duplicates within partitions
- Transaction coordination extends exactly-once guarantees across multiple partitions and topics
- Consumer design matters — even exactly-once delivery requires atomic processing and offset management
- Performance trade-offs are real — exactly-once comes with measurable overhead in throughput and latency
- Infrastructure-level solutions scale better than pushing complexity to every application
Understanding these mechanisms gives you the tools to evaluate messaging systems, design resilient applications, and implement similar guarantees in your own distributed systems architecture.