Idempotent consumers
A consumer is idempotent when handling the same message twice has the same effect as handling it once. With at-least-once deliveryDelivery guaranteesAt-most-once, at-least-once and exactly-once: what a broker promises, and what it cannot.Opens in a new tab the broker will sometimes send a message again, so the consumer has to cope.
How a duplicate happens
Orders receives PaymentReceived, marks the order paid and saves. Before it acknowledges the message, the process crashes. The broker sees no acknowledgement, the lock expires, and another instance receives the same message. Without protection the order is marked paid twice, a confirmation email goes out twice, and a counter goes up twice. Producer retries and messages resubmitted from a dead-letter queueDead-letter queuesWhere a message goes when it cannot be processed, so the rest of the traffic keeps moving.Opens in a new tab cause duplicates too.
Make the operation safe to repeat
Some operations need no extra bookkeeping.
| Operation | Safe to repeat | Why |
|---|---|---|
| Set the status to Paid | Yes | Same end state each time |
| Upsert a row by its business key | Yes | The second run finds the row and rewrites the same values |
| Add 10 to the balance | No | Each run adds again |
| Reduce stock by 1 | No | Each run subtracts again |
| Send an email | No | The recipient gets one per run |
An event that carries the resulting value, such as the order’s new status, can be applied as a set or an upsert. An event that carries a change, such as minus one, cannot.
When it is not natural: the inbox
For the rest, the consumer keeps a table of message IDs it has finished, called an inbox. It checks the table first, and it writes the ID in the same database transaction as the change.
public sealed class PaymentReceivedHandler(ShopDbContext db){ public async Task HandleAsync(PaymentReceived msg, CancellationToken ct) { var seen = await db.Inbox.AnyAsync(i => i.MessageId == msg.MessageId, ct); if (seen) return; var order = await db.Orders.FindAsync([msg.OrderId], ct); order!.MarkPaid(msg.PaymentId); db.Inbox.Add(new InboxEntry(msg.MessageId, DateTimeOffset.UtcNow)); await db.SaveChangesAsync(ct); }}The check alone is not enough. Two instances can receive copies at the same moment, and both pass the check before either saves. A unique index on MessageId closes that gap: the second SaveChangesAsync fails, the message is retried, and the retry finds the row. The save on line 12 is one transaction, so a crash leaves either both the order change and the inbox row, or neither (see DbContextORMs, DbContext and entitiesWhat EF Core is, and the four ideas its API is built on.Opens in a new tab). The producer-side partner is the outbox, in outbox and inboxOutbox and inboxWrite the event in the same transaction as the data, publish it from there, and deduplicate on the way in.Opens in a new tab.
Details that matter
- Key on a stable ID the producer assigns, such as
MessageId, or on a business key. ACorrelationIdis shared by related messages, and a broker’s delivery tag changes on every delivery. - Keep inbox rows at least as long as a redelivery is possible, which includes a message resubmitted from a dead-letter queue days later. Then delete them.
- If two services consume the same event, each keeps its own record of it.
- A call to an outside service cannot join the transaction. Pass the message ID on as an idempotency key so the other side can deduplicate, or record the key as in progress, make the call, then mark it completed.
- Broker-side duplicate detection, such as Service Bus’s, drops repeated sends within a time window (10 minutes by default). It does not cover a redelivery to a consumer, so it does not replace this.
Sources
- Idempotent Consumer pattern, Azure Architecture Center · accessed June 12, 2026
- Message transfers, locks, and settlement, Microsoft Learn · accessed June 12, 2026
- Duplicate detection, Azure Service Bus, Microsoft Learn · accessed June 12, 2026