InfoWok

Message Delivery Guarantees and Idempotency: At-Most, At-Least and ‘Exactly’ Once (2026)

A broker cannot make a message arrive exactly once. It can promise at-most-once or at-least-once, and you make at-least-once safe with idempotent processing. The order of "process" and "acknowledge" decides the guarantee, and a dedupe table makes duplicates harmless.

SK
Sukhveer Kaur
Published October 24, 2026
10 min read
Open in ChatGPT Open in Claude
Message delivery guarantees, system design lesson: acknowledging before processing loses messages on a crash, processing before acknowledging duplicates them, and an idempotent consumer makes duplicates harmlessSoftware Architecture
DELIVERY GUARANTEES
On this page +

A customer is charged twice for one order. The payment worker did its job: it read the message, charged the card, and was about to tell the queue “done”. Then the process crashed. The queue never heard the acknowledgement, assumed the message was lost, and sent it to another worker. The second worker charged the card again.

Nothing was broken. This is the system working as designed, and it is the most important idea in messaging. This lesson shows what the guarantees mean, why “exactly once” needs a second ingredient, and how to make duplicates harmless.

Intermediate30 min
Before you start
Remember from the Kafka vs RabbitMQ lesson

When does RabbitMQ consider a message handled? When the consumer acknowledges it. Kafka does the equivalent by saving the consumer’s position in the log. This lesson is about the moment between doing the work and reporting it.

Key takeaways
  • A broker can promise at-most-once or at-least-once. It cannot promise that a message is processed exactly once.
  • The order of “process” and “acknowledge” decides the guarantee.
  • Any consumer on an at-least-once system must expect duplicates.
  • An idempotent consumer, usually a dedupe table with a unique constraint, turns at-least-once into effectively-once.
  • Retries need a limit and a dead-letter queue, or one bad message blocks everything.
After this lesson you can

Say which delivery guarantee a design gives and why, make a consumer safe against duplicates, and explain why exactly-once delivery is really at-least-once plus idempotency.

The Three Guarantees#

Kafka’s documentation lists the options plainly. In its message delivery semantics section:

GuaranteeWhat it promises
At most oncemessages may be lost but are never redelivered
At least oncemessages are never lost but may be redelivered
Exactly onceeach message is processed once and only once
An analogy: the post

A plain letter dropped in a box may vanish and is never sent twice: at most once. A parcel that needs a signature is sent again until someone signs: at least once. Exactly once would mean the parcel arrives once even if the courier’s van breaks down mid-route, and nobody can promise that.

The documentation splits the problem in two: durability when publishing and guarantees when consuming. Out of the box, Kafka guarantees at-least-once; you get at-most-once only by disabling producer retries and saving the consumer’s position (its offset) before processing.

One Crash, Two Outcomes#

On the consuming side, a consumer does two things: it processes a message and it acknowledges it. Only the order differs, and the order picks the guarantee.

Kafka’s documentation describes the same two choices for a consumer that saves its position. Save first, then process, and a crash in between means the replacement starts after messages that were never processed: at-most-once. Process first, then save, and the first few messages the replacement receives were already processed: at-least-once.

Predict first

A consumer processes a message, then the machine loses power before it acknowledges. What does the broker do?

It cannot know the work happened, so it delivers the message again. RabbitMQ’s documentation says so directly: when manual acknowledgements are used, any delivery that was not acked is automatically requeued when the channel or connection closes, and consumers must be prepared to handle redeliveries and be implemented with idempotence in mind.

RabbitMQ’s automatic acknowledgement mode counts a message as delivered once it is sent. The documentation says it is unsafe and not suitable for all workloads: at-most-once in practice.

Cloud queues use a timer instead of a connection#

Amazon SQS has no open channel to watch, so it uses a visibility timeout. A received message is hidden, not removed, and the consumer deletes it when done. If it is not deleted in time, it becomes visible again and another consumer can take it. The default is 30 seconds.

A duplicate with no crash

Set the timeout shorter than your slowest job and two workers run the same message at once. Even inside the window, standard queues are at-least-once, and AWS tells you to use idempotent operations.

The publishing side#

The same uncertainty exists when sending, and each broker answers it differently:

  • Kafka. After a network error, a producer cannot tell whether the message was committed, so it resends: at-least-once. The idempotent producer (since 0.11.0.0) lets the broker discard the repeats. In the 4.3 docs enable.idempotence defaults to true unless conflicting settings turn it off.
  • RabbitMQ. A publisher that has written a message to its socket cannot assume it reached the server, so the broker sends a publisher confirm, which mimics the consumer acknowledgement.
  • SQS FIFO. A send that is retried with the same deduplication ID within 5 minutes is not added twice. Note what that covers: duplicate sends, inside a window. A consumer that crashes before deleting still sees the message again.

Saving and sending: the dual write#

One more gap sits before the broker. A service saves an order, then publishes “order placed”. If it crashes between the two, the order exists and nobody hears about it. Publishing first has the opposite failure.

The fix is the transactional outbox. The service writes the message into an outbox table in the same transaction as the order, and a separate relay publishes from there.

The outbox pattern write-up names the catch: a relay that crashes after publishing but before marking the row sent publishes it again. The outbox removes lost messages, not duplicates, so the consumer still has to be idempotent.

”Exactly Once” Is a Claim to Read Carefully#

Kafka’s documentation is blunt: many systems claim exactly-once delivery, but read the fine print, because the claims can mislead when consumers or producers fail, when there are multiple consumer processes, or when data written to disk is lost.

What real systems offer is effectively once: deliver at least once, then arrange that a repeat has no extra effect. There are three ways.

  1. Idempotent producer. Kafka’s producer can ask the broker to assign it an ID and deduplicate messages using a sequence number, so resending does not create duplicate entries.
  2. Commit the result and the position together. For a Kafka-to-Kafka pipeline, the consumer’s offset is written in the same transaction as the output, so either both happen or neither does. Readers must set isolation.level=read_committed; the default, read_uncommitted, also shows aborted writes.
  3. Idempotent processing. For everything else, the work itself must be safe to repeat.

For an external system such as a database or a payment provider, the documentation says exactly-once delivery generally requires cooperation with the destination, for example storing the offset with the output. In plain terms: you have to make the effect idempotent.

Making a Consumer Idempotent#

An operation is idempotent if repeating it changes nothing the second time.

OperationSafe to repeat?
set the order status to “paid”yes
upsert a row by primary keyyes
add 10 to a balanceno
charge a card, send an emailno

Some work is naturally safe. Kafka’s documentation points out that when messages have a primary key, updates are idempotent, because receiving the same message twice just overwrites a record with another copy of itself. Charging a card or sending an email is not safe, so those need a guard.

The usual guard is a dedupe table: record each message ID, with a unique constraint, in the same transaction as the work.

text
on message(m):
  BEGIN
  INSERT processed(m.id)
    -- fails if id seen
  UPDATE orders SET ...
  COMMIT
  ack(m)

If the insert fails because the ID exists, the consumer rolls back, acknowledges, and skips the work. A crash after the commit but before the ack is now safe: the redelivery hits the unique constraint and does nothing. The same constraint also covers two workers holding the same message at once, as after an expired visibility timeout: only one insert wins.

Two details decide it

The ID must come from the sender (an order ID, a request ID), not be made up per delivery, or every redelivery looks new. And the ID and the effect must commit together, or a crash between them brings the problem back.

When the effect is outside your database#

A card charge happens at the payment provider, so your transaction cannot roll it back. Here the dedupe has to live where the effect lives: pass the sender’s ID to the provider as an idempotency key. Stripe’s API, for example, returns the saved result when a request is retried with the same Idempotency-Key, and errors if the parameters differ.

Keys are up to 255 characters, and every dedupe store forgets eventually. A repeat that arrives after that is treated as new:

Dedupe storeRemembers an ID for
Stripe idempotency keysmay be pruned after 24 hours
SQS FIFO send dedupe5 minutes
your own dedupe tableas long as you keep the rows

Keep IDs for longer than a redelivery can arrive, including a replay from the dead-letter queue days later.

Retries, Poison Messages and Dead Letters#

Sometimes a message fails every time: bad data, a bug, a missing record. Requeueing it at once creates a loop. RabbitMQ’s documentation warns that such a requeue and redelivery loop can be costly in network bandwidth and CPU, and suggests tracking redeliveries and either rejecting for good or requeueing after a delay.

A dead-letter queue is where those messages go. Each broker decides when to move them:

  • RabbitMQ dead-letters a message when it is rejected with requeue set to false, expires, or overflows a queue length limit.
  • RabbitMQ quorum queues also dead-letter a message returned more times than the delivery limit, which defaults to 20 since 4.0. With no dead-letter target set, the message is dropped. Since 4.3 the count ignores explicit nacks that set delivery_failed=false.
  • SQS uses a redrive policy: after maxReceiveCount receives without a delete, the message moves to the dead-letter queue.

A sensible policy has three parts:

  • Retry a few times with growing, slightly random delays (backoff with jitter), so many failing consumers do not retry in lockstep.
  • Then dead-letter the message so the queue keeps moving.
  • Alert and replay. Someone looks, fixes the cause, and sends the messages back through.

Retries also break order: a message that fails comes back after later ones. The table below says what to do.

Choosing a Guarantee#

WorkloadReasonable choice
Metrics, logs, telemetry you can loseat-most-once, simplest and fastest
Orders, payments, emailsat-least-once plus an idempotent consumer
Kafka-to-Kafka processingtransactions: commit output and offset together
Save a row, then publish an eventoutbox, plus an idempotent consumer

At-least-once with idempotency is the default answer for anything that matters. Be wary of any design that depends on “exactly once” with no idempotent step you can point to.

Interview Follow-ups#

Ask first whether a message can be lost, whether the effect can repeat, and whether order matters per key. State your assumption aloud. The companion lesson Message Ordering, Rebalances, Transactions and Sagas answers the rest of the follow-ups.

ProbeShort answer
Ordering at scale?Use the entity ID as the key, so one entity’s events share a partition. Retries still reorder.
Consumer group rebalance?A consumer too slow to poll is treated as failed and its work is repeated. The dedupe check absorbs it.
Several services must agree?No single transaction spans them. The companion covers sagas, Kafka transactions and outbox relays.
Level signals

Mid: names at-least-once and idempotency. Senior: adds the dedupe transaction, external keys, DLQ policy and rebalance duplicates. Staff: also covers dedupe retention, replay, outbox or sagas across services, and what to monitor. These are this guide’s judgement, not a published rubric.

Where AI Systems Hit This#

Agents retry too. A model that calls a refund tool, times out, and calls it again is the same problem: two deliveries, one intended effect. Give the action a stable ID and make the tool ignore repeats. The agent interview lesson uses this example, and the guardrails lesson covers checks around tool calls.

Mistakes that cost points
  • Saying “the queue guarantees exactly-once” with no idempotent step.
  • Generating a new ID on each retry, which defeats deduplication.
  • Retrying forever with no limit and no dead-letter queue.
  • Saving the message ID and doing the work in two separate steps.
  • Writing to the database and publishing in two steps, with no outbox.

Try It: Count the Duplicates#

Exercise · How many duplicates can crashes cause?8 min

A payment consumer uses RabbitMQ with manual acknowledgements and a prefetch of 50, which limits how many unacknowledged messages it can hold. Assume it crashes 3 times a day, and each time it holds a full 50 unacknowledged messages that it has already processed. Decide:

  1. What is the most redelivered messages per day?
  2. What happens to the customers if the charge is not idempotent?
  3. What one change fixes it?
Hint

Multiply crashes by messages held at a crash. Then think about what a redelivered, already-processed payment message does.

One answer
text
Per crash: up to 50 repeats
3 crashes/day: up to 150
Each repeat is a charge that
was already done, so up to
150 double charges a day
Fix: send the order id as
the provider's idempotency
key on every attempt

Redelivery after a crash is normal, so the fix cannot be “avoid crashes”. The charge happens at the provider, outside your transaction, so the order ID goes to it as the idempotency key. A local dedupe table protects your own database. Lowering the prefetch shrinks the worst case but does not remove it.

In an interview

When asked “can you guarantee exactly-once?”, do not say yes. Say: “The broker gives at-least-once. A dedupe table keyed by the order ID, committed with the effect, makes redeliveries harmless, and payment calls carry the same ID as an idempotency key. Messages that keep failing go to a dead-letter queue after a few retries.” Then name the limit: a third party with no idempotency key, or a dedupe store whose IDs expired, can still see a repeat.

Check Yourself#

Check yourself5 questions · answer, then open
1.A consumer acknowledges a message and then crashes before finishing the work. Which guarantee is this, and what is the cost?

At-most-once. The message is acknowledged, so it is never redelivered, and the unfinished work is lost.

2.Why is a dedupe ID generated by the consumer on each delivery useless?

Each redelivery would get a new ID, so every repeat looks like new work. The ID must come from the sender and stay the same across retries.

3.You must send exactly one welcome email per signup, and the queue is at-least-once. What do you do?

The send is outside your database, so no single transaction covers it. Use the email provider’s idempotency key if it has one, keyed by the signup ID. If it does not, record the signup ID before sending and skip any repeat; a crash between the record and the send can then lose one email, so pick which failure you prefer and say so.

4.What would change if this consumer read from SQS and a job sometimes took longer than the visibility timeout?

The message would become visible again while the first worker is still busy, so a second worker runs it at the same time, with no crash at all. Raise the timeout above the slowest job or extend it while working, and keep the dedupe check: the unique constraint lets only one of the two commit.

5.One message fails every time and blocks the queue. What is the fix?

Limit retries, with growing delays, then move the message to a dead-letter queue and alert, so the rest of the queue keeps moving and someone can fix and replay it.

Learning tip

Draw receive, process, acknowledge from memory. Mark where a crash loses the message and where it duplicates it, then add the dedupe check. Repeat tomorrow.

In One Picture#

text
At most   ack first, may lose
At least  ack last, may repeat
Exactly   at least once +
  idempotent work
Dedupe    sender id, unique key,
  same transaction as the effect
External  idempotency key, keep
  ids past the retry window
Publish   outbox; relay repeats
Failures  few retries, then DLQ

Deliver at least once, make the work idempotent, and send the stubborn failures to a dead-letter queue.

Where to go from here

Next in the course: design a URL shortener, the first full design in the classic problems module.

Frequently asked questions

What is the difference between at-most-once, at-least-once and exactly-once delivery? +
At-most-once means a message may be lost but is never redelivered. At-least-once means it is never lost but may be delivered more than once. Exactly-once means each message is processed once and only once, which in practice is built from at-least-once delivery plus idempotent processing.
Is exactly-once delivery possible? +
Not as a property of the network alone. Kafka's documentation warns to read the fine print on exactly-once claims. What systems offer is effectively-once: deliver at least once, then make processing idempotent or commit the result and the position together.
What is an idempotent consumer? +
A consumer for which processing the same message twice has the same effect as processing it once. A common way is to record each message ID in a table with a unique constraint, in the same transaction as the work.
Why does a consumer receive duplicates? +
Because a message that was not acknowledged is redelivered. If a consumer crashes after doing the work but before acknowledging, the broker cannot know the work happened, so it sends the message again.
What is the transactional outbox pattern? +
A service writes its business change and the outgoing message into an outbox table in one database transaction, and a separate relay publishes from the outbox. No event is lost if the service crashes, but the relay can publish a message twice, so consumers must still be idempotent.
What is a dead-letter queue? +
A place where messages go when they cannot be processed, for example after repeated failures, so they stop blocking the queue. Someone can inspect them, fix the cause and replay them.

References

  1. Apache Kafka 4.3 documentation: Design, Message Delivery Semantics
  2. Apache Kafka 4.3 documentation: Producer configs (enable.idempotence)
  3. Apache Kafka 4.3 documentation: Consumer configs (isolation.level)
  4. RabbitMQ 4.4 documentation: Consumer Acknowledgements and Publisher Confirms
  5. RabbitMQ 4.4 documentation: Dead Letter Exchanges
  6. RabbitMQ 4.4 documentation: Quorum Queues (poison message handling)
  7. Amazon SQS Developer Guide: queue types
  8. Amazon SQS Developer Guide: visibility timeout
  9. Amazon SQS Developer Guide: exactly-once processing in FIFO queues
  10. Amazon SQS Developer Guide: dead-letter queues
  11. Stripe API reference: Idempotent requests
  12. microservices.io: Pattern, Transactional outbox
Written by
Sukhveer Kaur
Sukhveer KaurSoftware Developer & AI Engineer

Sukhveer is a software developer specialising in AI systems and backend engineering. She has hands-on experience designing agentic AI applications, working with large language model pipelines, autonomous agent frameworks, and cloud-native services in Java and Python. At InfoWok, she bridges the gap between cutting-edge AI research and practical implementation — helping developers understand and apply emerging technologies through clear, experience-backed writing.

New AI engineering guides, the day they ship

Real Python, production depth. No digest spam.

Comments