The Inbox Pattern in .NET: Handle Each Message Exactly Once
Learn the Inbox Pattern in .NET to stop duplicate messages from causing double charges and double emails. Simple real-life examples, EF Core code, diagrams, and how it pairs with the Outbox Pattern.
The guard with a guest register
Imagine a wedding hall with a guard at the gate. Each guest has an invitation card with a unique number. The guard keeps a register. When a guest arrives, the guard checks the number against the register:
- If the number is not in the register, the guard writes it down and lets the guest in.
- If the number is already in the register, the guard knows this guest is already inside — maybe they stepped out and came back — and does not seat them again.
Because of this simple register, no guest ever gets seated twice, no matter how many times they walk through the gate.
The Inbox Pattern is exactly this guard. In messaging systems, the same message can arrive more than once. Without a register, your app might charge a customer twice or send two confirmation emails. The inbox keeps a register of message ids it has already seen, so each message is handled once in effect, even if it is delivered many times.
Let us see why duplicates happen, and how to build the inbox in .NET.
Why the same message arrives twice
Most message brokers (RabbitMQ, Azure Service Bus, Kafka) and the Outbox Pattern promise at-least-once delivery. That word "at-least" is the catch — it means one or more times, never zero, but sometimes more than once.
Here is the classic moment it goes wrong. A consumer receives an OrderPaid message, does the work (charges the card, updates the order), and then must acknowledge the broker so it stops re-sending. But what if the consumer crashes after the work but before the acknowledgement?
The broker never heard the acknowledgement, so it assumes the message failed and sends it again. The consumer happily charges the card a second time. The customer is furious. This is the problem the inbox solves.
The idea: write the id before doing the work
The inbox adds one rule to your consumer:
Before doing any real work, record the message's unique id. If that id is already recorded, skip the work entirely.
Because the id is unique per message, the second delivery finds its id already in the table and stops. The card is charged exactly once in effect.
How the Inbox Pattern Works
Steps
Arrive
A message comes in from the broker
Check
Look up the message id in the inbox table
Decide
New id → handle it. Known id → ignore it
Record + work
Save the id and do the work in ONE transaction
Acknowledge
Tell the broker it is done, safely
Step 1: The inbox table
The inbox is a simple table whose job is to remember which messages were handled:
public class InboxMessage
{
public Guid MessageId { get; set; } // unique id from the producer
public string Type { get; set; } = default!;
public DateTime ReceivedOnUtc { get; set; }
public DateTime? ProcessedOnUtc { get; set; }
}The MessageId is the primary key. That single choice is powerful: the database itself will refuse to insert the same id twice, giving you deduplication for free.
Step 2: The deduplicating consumer
Now the consumer checks the register before working, and records the id in the same transaction as the work:
public async Task Handle(OrderPaid message, CancellationToken ct)
{
// 1. Has this exact message already been handled?
bool alreadyHandled = await _db.InboxMessages
.AnyAsync(x => x.MessageId == message.MessageId, ct);
if (alreadyHandled)
return; // duplicate — the guard says "already inside"
// 2. Record the id AND do the work together — one transaction.
_db.InboxMessages.Add(new InboxMessage
{
MessageId = message.MessageId,
Type = nameof(OrderPaid),
ReceivedOnUtc = DateTime.UtcNow,
ProcessedOnUtc = DateTime.UtcNow,
});
await ChargeCard(message.OrderId, ct); // the real work
await _db.SaveChangesAsync(ct); // both commit, or neither does
}The magic is in step 2: recording the id and doing the work share one transaction. If the work fails, the id is not saved either, so the message can be safely retried. If both succeed, the duplicate next time is caught at step 1.
Handling a race: two copies at the same time
There is one more edge case. What if two copies of the same message are processed at the same moment on two servers? Both might check the inbox, both see "not handled," and both try to work. The AnyAsync check alone does not stop this.
Two reliable defences:
- Let the primary key win. Because
MessageIdis the primary key, when both try toINSERTthe same id, the database lets one succeed and throws a unique-constraint error on the other. Catch that error and treat it as "already handled." - Lock the row. Advanced setups read inbox rows with
FOR UPDATE SKIP LOCKED(in PostgreSQL) so each worker grabs different messages and never collides.
The simplest robust version is defence #1: rely on the unique primary key. Wrap the save in a try/catch, and if a duplicate-key error fires, you know another worker already handled it — so you safely ignore it. No extra locking code needed.
In code, that safety net looks like this:
try
{
_db.InboxMessages.Add(new InboxMessage { MessageId = message.MessageId, /* ... */ });
await ChargeCard(message.OrderId, ct);
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (IsDuplicateKey(ex))
{
// Another worker already handled this exact message — safe to ignore.
return;
}The database is the final referee. Even if two workers race past the first check at the very same instant, only one INSERT of that MessageId can win. The loser catches the duplicate-key error and quietly steps aside. This is far safer than trusting the AnyAsync check alone.
Inbox vs Idempotent Consumer
These two terms are often mixed up. They solve the same problem in slightly different ways:
| Idempotent Consumer | Inbox Pattern | |
|---|---|---|
| Core idea | Check a dedup table, do the work, record id — inline | Record the message first, process now or later |
| Best for | Simple cases, broker retries are enough | Batching, custom retries, scaling across workers |
| Processing | Always inline, in the handler | Can be separated into a background worker |
| Complexity | Lower | A little higher |
Think of the idempotent consumer as the quick version and the inbox as the fuller version of the same idea. For many apps the idempotent consumer is enough; reach for the full inbox when you need batching, custom retry logic, or to scale processing across many workers.
The complete pipeline: Outbox + Inbox
The inbox truly shines when paired with the Outbox Pattern. The outbox makes sure a message is never lost when sending; the inbox makes sure it is never processed twice when receiving. Together they cover the two failure modes that corrupt event-driven systems.
Outbox + Inbox: End-to-End Reliability
Steps
Outbox
Sender saves data + message together, never loses it
Deliver
Broker delivers at-least-once, possibly twice
Inbox
Receiver checks the id and drops duplicates
Once
The work happens exactly once in effect
Best practices
- Use the message id as the primary key. This single decision gives database-level deduplication for free.
- Record the id and do the work in one transaction. Never split them, or you risk recording an id for work that did not happen.
- Clean up old rows. Inbox tables grow forever if you let them. Archive or delete rows older than your retry window.
- Prefer a library when you can. MassTransit and NServiceBus ship inbox/outbox support that is tested and battle-hardened, so you do not have to maintain your own.
- Make the unique id meaningful. Use the producer's stable message id, not a freshly generated one on the consumer — otherwise every delivery looks "new." This is the single most common mistake: if the sender does not stamp a stable id, the inbox has nothing reliable to deduplicate on, and every copy slips through as if it were the first.
- Index the lookup column. The "have I seen this id?" check runs on every single message, so make sure the
MessageIdis indexed (being the primary key already gives you this) to keep it instant even with millions of rows.
When to use it
Use the Inbox Pattern whenever doing the same work twice would hurt — charging cards, sending emails, shipping goods, updating stock. Any consumer of at-least-once messages that has real side effects should be protected.
You can skip it for naturally idempotent operations. For example, "set the user's status to Active" can run twice with no harm, because the second run changes nothing. But the moment your handler adds, charges, or sends, you need the guard at the gate.
This table makes the line clear:
| Operation | Run twice = harm? | Needs inbox? |
|---|---|---|
| Charge a card | Yes — double charge | Yes |
| Send a confirmation email | Yes — two emails | Yes |
| Add stock movement / ledger row | Yes — wrong totals | Yes |
| Set status = "Active" | No — same result | No |
| Overwrite a value with a fixed value | No — same result | No |
The simple test: does running the handler a second time change anything compared to the first run? If yes, protect it with the inbox. If the second run is a harmless no-op, you may not need it.
Quick recap
- Brokers and the outbox deliver at-least-once, so the same message can arrive more than once.
- The Inbox Pattern records each message's unique id before doing the work, and skips any id it has already seen.
- Make
MessageIdthe primary key so the database deduplicates for you, and record the id in the same transaction as the work. - It is the fuller cousin of the Idempotent Consumer, and pairs with the Outbox Pattern for end-to-end reliability.
- Use it whenever repeating an action would cause real harm; skip it only for naturally safe, repeatable operations.
Like the wedding guard with a register, the inbox lets messages knock as many times as they like — but each one only gets through once.
References and further reading
- Pattern: Idempotent Consumer — microservices.io — the canonical description of the idea.
- Implementing the Inbox Pattern — Milan Jovanović — a clear, well-loved .NET walkthrough.
- MassTransit transactional outbox docs — how a popular .NET library implements inbox/outbox for you.
Related Patterns
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
Idempotent Consumer: Handling Duplicate Messages in .NET
Learn the Idempotent Consumer pattern in .NET to safely handle duplicate messages, prevent double charges, and build reliable message-driven systems.
Outbox Pattern for Reliable Microservices Messaging in .NET
Learn the Outbox Pattern in .NET to stop losing messages between microservices. Simple analogy, EF Core code, diagrams, and best practices for reliable messaging.
The Idempotent Consumer Pattern in .NET (And Why You Need It)
A friendly .NET guide to the idempotent consumer pattern: stop duplicate messages from double-charging customers using message ids, transactions, and EF Core.
Scaling the Outbox Pattern in .NET: From Hundreds to Billions of Messages
Scale the Outbox Pattern in .NET to billions of messages a day with batching, indexes, SKIP LOCKED, and parallel workers — explained simply with diagrams.
Event-Driven Microservices with Azure Service Bus in .NET
A friendly, step-by-step guide to building event-driven microservices in .NET using Azure Service Bus topics, subscriptions, and the ServiceBusProcessor.