The idea: where the ack and the commit sit
A message travels two hops: the producer sends it to a broker, and a consumer reads it and does the work (here, adds $10 to a balance in a database). Either hop can fail half-way: a packet or its acknowledgement is lost, or the consumer crashes between doing the work and recording that it did. What happens then depends only on when each side says "done": before the work or after it. That choice is the delivery semantics.
The page runs the same three payments m1, m2, m3 through three pipelines on one clock. Each message takes one tick. The scoreboard under each lane counts payments lost, payments applied twice (duplicates) and the messages it took (data, acks, fetches, commits and database writes). The right answer is a balance of $30.
At-most-once
The producer uses acks=0: it sends and moves on, so it never learns that a message was lost (Demo: m2 lost on the way to the broker). The consumer commits its offset first and processes the record after. If it crashes in between, the restarted consumer starts after the record, and the payment is never applied (Demo: consumer crashes during m2). Every message is delivered zero or one times. In return it is the cheapest: no acks, no waiting, the fewest messages (Demo: no failures).
It is the right choice when a lost message costs less than a repeated one, or nothing at all: metrics samples, log lines, sensor readings that the next reading replaces.
At-least-once
The producer waits for the ack (acks=all) and sends again when none comes back in time (retries). The consumer processes first and commits after. Nothing is lost, but there are two ways to get a copy:
- the broker wrote the message, but its ack was lost: the producer cannot tell this from a lost message, retries, and the log now holds m2 twice (Demo: the broker's ack for m2 is lost, the red cell);
- the consumer applied the record and crashed before committing: the restart reads it again from the last committed offset (Demo: consumer crashes during m2).
This is the default of most messaging systems (Kafka with manual commits, RabbitMQ with manual acks, SQS). It is correct whenever the work is idempotent: setting a value, upserting a row, deleting something.
Exactly-once (an effect that happens once)
No network can deliver a message exactly once: the sender can never be sure the other side got it without a reply, and the reply can be lost. What systems call exactly-once is at-least-once delivery plus deduplication at each hop, so the effect happens once:
- Idempotent producer (
enable.idempotence=true, the default since Kafka 3.0). The broker gives the producer an id (PID) and the producer numbers its records per partition. A retried record arrives with a sequence number the broker already has, and the broker acks it without writing it again. - Idempotent consumer. The consumer writes the effect and the message's key (its id, or the topic-partition-offset) into the database in one transaction, with a unique constraint on the key. A record read again after a crash finds its key and changes nothing. Storing the offset itself in that transaction works the same way.
The price is extra state (the processed-keys table, the producer's sequence numbers) and the rule that the effect must live in the same transaction as the key. In Demo: lost ack + crash during m3 at-most-once loses m3, at-least-once applies m2 and m3 twice ($50), and exactly-once ends at $30.
Which one?
| At-most-once | At-least-once | Exactly-once (effect) | |
|---|---|---|---|
| producer | acks=0, no retry | acks=all + retries | + idempotent producer (PID, seq) |
| consumer | commit, then process | process, then commit | effect + key (or offset) in one transaction |
| lost message / lost ack | lost / fine | fine / duplicate | fine / fine |
| consumer crash mid-record | lost | duplicate | fine |
| cost | lowest | acks, retries | acks, retries, a dedup store |
| good for | metrics, logs, telemetry | idempotent work, most pipelines | money, counters, anything not idempotent |
See also Kafka for the producer, broker and consumer group in detail, Kafka Consumer Groups for where duplicates come from in a rebalance and for Kafka transactions, Kafka Replication for what acks=all waits for, and Transactional Outbox and Inbox for the same dedup-key idea between services.
What the page leaves out
Kafka transactions (transactional.id, sendOffsetsToTransaction, isolation.level=read_committed), which make a read-process-write loop inside Kafka atomic; side effects outside the database (an e-mail or a card charge cannot join the transaction, so it needs its own idempotency key at the other service); fetch requests (only the responses are drawn); batches (max.poll.records above 1 and auto-commit, which can lose or repeat a whole batch); several partitions and max.in.flight.requests.per.connection; broker failover and replication; rebalances; and how long the processed keys must be kept.