Skip to main content
SEMastery

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.

11 min readUpdated February 16, 2026

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?

Figure 1: The duplicate-delivery problem. The consumer crashes before acknowledging, so the broker re-sends and the work runs twice.

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

Message Arrives
Check Inbox Table
Seen Before?
Record + Process
Skip Duplicate

Steps

1

Arrive

A message comes in from the broker

2

Check

Look up the message id in the inbox table

3

Decide

New id → handle it. Known id → ignore it

4

Record + work

Save the id and do the work in ONE transaction

5

Acknowledge

Tell the broker it is done, safely

Every incoming message is checked against the inbox register first. Only brand-new messages get processed.

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.

Figure 2: With the inbox, the re-delivered message is recognised by its id and skipped — the card is charged only once.

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:

  1. Let the primary key win. Because MessageId is the primary key, when both try to INSERT the 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."
  2. 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 ConsumerInbox Pattern
Core ideaCheck a dedup table, do the work, record id — inlineRecord the message first, process now or later
Best forSimple cases, broker retries are enoughBatching, custom retries, scaling across workers
ProcessingAlways inline, in the handlerCan be separated into a background worker
ComplexityLowerA 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

Service A + Outbox
Broker
Service B + Inbox
Handled Once

Steps

1

Outbox

Sender saves data + message together, never loses it

2

Deliver

Broker delivers at-least-once, possibly twice

3

Inbox

Receiver checks the id and drops duplicates

4

Once

The work happens exactly once in effect

The outbox guards the sender against loss. The inbox guards the receiver against duplicates. Together: reliable, exactly-once-in-effect messaging.
Figure 3: The two patterns as a state machine — a message moves from saved, to delivered, to handled, and duplicates loop back to 'already handled'.

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 MessageId is 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:

OperationRun twice = harm?Needs inbox?
Charge a cardYes — double chargeYes
Send a confirmation emailYes — two emailsYes
Add stock movement / ledger rowYes — wrong totalsYes
Set status = "Active"No — same resultNo
Overwrite a value with a fixed valueNo — same resultNo

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 MessageId the 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

Related Patterns