Skip to main content
SEMastery

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.

12 min readUpdated December 1, 2025

A letter in the outbox tray

Think about how you post a letter from home. You write the letter, put it in an envelope, and drop it in the outbox tray near your door. You do not run to the post office yourself. Later, the postman comes, picks up whatever is in the tray, and delivers it.

Why is this a good system? Because even if the postman is late, sick, or stuck in traffic, your letter is safe in the tray. It will be delivered when the postman is back. You did your job (writing and placing the letter) in one smooth step, and delivery is handled separately and reliably.

The Outbox Pattern in software works exactly like this tray. Your app saves its data and drops a "letter" (a message) into a special outbox table — all in one safe step. A background worker, like the postman, picks up those messages later and delivers them to the message broker. Even if the broker is down for a while, no message is ever lost.

Let us understand why we need this, and how to build it in .NET step by step.

The dual-write problem (the bug that hides for months)

Imagine an online shop. When a customer places an order, your code usually does two things:

  1. Save the order to the database.
  2. Send an OrderPlaced message to a broker (like RabbitMQ) so other services — email, shipping, analytics — can react.

The naive code looks innocent:

public async Task PlaceOrder(Order order)
{
    await _dbContext.Orders.AddAsync(order);
    await _dbContext.SaveChangesAsync();      // Step 1: save to database
 
    await _bus.Publish(new OrderPlaced(order.Id)); // Step 2: publish message
}

This works fine on your machine. But in production, networks fail and services restart. Now think about what happens if something breaks between step 1 and step 2:

  • The order is saved ✅
  • But the broker is down, so the publish fails ❌

The customer is charged and the order exists, but the email service never hears about it. No confirmation email, no shipping. The two systems now disagree. This is called the dual-write problem — you are writing to two places that cannot share one transaction, so they can drift apart.

Figure 1: The dual-write problem. If the app crashes after saving but before publishing, the message is lost forever.

You might think "I will just publish first, then save." That only flips the problem — now you could publish a message for an order that never got saved (a "ghost" message). There is no ordering that fixes it, because the database and the broker are two separate systems with two separate transactions.

The fix: write the message into the same database

The Outbox Pattern solves this with one clever idea:

Instead of writing to the database and the broker, only write to the database — including the message itself — in a single transaction.

Because the order row and the message row are saved together, they either both succeed or both fail. There is no in-between. A separate background worker reads the saved messages and publishes them later, retrying until the broker confirms.

How the Outbox Pattern Works

Save Order + Outbox Row
Commit Transaction
Worker Polls Outbox
Publish to Broker
Mark as Processed

Steps

1

Save together

Order and message saved in ONE transaction

2

Commit

Both succeed or both fail — never half

3

Poll

A background worker reads unsent messages

4

Publish

Send to RabbitMQ / Azure Service Bus, with retries

5

Mark done

Flag the row as processed so it is not sent twice

The order and its message are saved together. A separate worker delivers the message later, with retries.

Step 1: Create the outbox table

The outbox is just a normal database table. Each row is one message waiting to be sent:

public class OutboxMessage
{
    public Guid Id { get; set; }
    public string Type { get; set; } = default!;   // e.g. "OrderPlaced"
    public string Content { get; set; } = default!; // the message as JSON
    public DateTime OccurredOnUtc { get; set; }
    public DateTime? ProcessedOnUtc { get; set; }   // null = not yet sent
    public string? Error { get; set; }              // last failure, if any
}

The two important columns are ProcessedOnUtc (is it sent yet?) and OccurredOnUtc (so we send in order). We will index these for speed later.

Step 2: Save the message in the same transaction

Now we change our order code. Instead of publishing to the broker, we add an outbox row to the same DbContext. When SaveChangesAsync runs, EF Core wraps it all in one transaction:

public async Task PlaceOrder(Order order)
{
    await _dbContext.Orders.AddAsync(order);
 
    var message = new OutboxMessage
    {
        Id = Guid.NewGuid(),
        Type = nameof(OrderPlaced),
        Content = JsonSerializer.Serialize(new OrderPlaced(order.Id)),
        OccurredOnUtc = DateTime.UtcNow,
    };
    await _dbContext.OutboxMessages.AddAsync(message);
 
    // ONE transaction — order and message commit together, or not at all.
    await _dbContext.SaveChangesAsync();
}

That is the heart of the pattern. The order and its message now share the same fate.

💡

A cleaner version uses an EF Core interceptor that runs inside SaveChangesAsync, scans your entities for domain events, and writes the outbox rows automatically. Your business code never even sees the outbox — it just raises events. This is the production-favourite approach.

Step 3: The background worker (our postman)

A hosted background service wakes up every few seconds, reads unsent messages, publishes them, and marks them done:

public class OutboxProcessor : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IBus _bus;
 
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
 
            var messages = await db.OutboxMessages
                .Where(m => m.ProcessedOnUtc == null)
                .OrderBy(m => m.OccurredOnUtc)
                .Take(20)                  // process in small batches
                .ToListAsync(ct);
 
            foreach (var message in messages)
            {
                try
                {
                    await _bus.Publish(message.Type, message.Content, ct);
                    message.ProcessedOnUtc = DateTime.UtcNow; // mark as done
                }
                catch (Exception ex)
                {
                    message.Error = ex.Message; // keep it, try again next time
                }
            }
 
            await db.SaveChangesAsync(ct);
            await Task.Delay(TimeSpan.FromSeconds(5), ct);
        }
    }
}

If the broker is down, the publish throws, the message stays unprocessed, and the worker simply tries again on the next loop. Nothing is lost. When the broker comes back, every pending message flows out.

At-least-once delivery: the one thing to remember

The Outbox Pattern promises at-least-once delivery, not exactly-once. Here is the tricky moment: what if the worker publishes a message successfully, but crashes before it can set ProcessedOnUtc?

When it restarts, that message still looks unprocessed, so it gets published again. The same OrderPlaced is delivered twice.

⚠️

Because messages can arrive more than once, the receiving service must be safe against duplicates. This is called being idempotent — handling the same message twice has the same effect as handling it once. A common way to do this is the Inbox Pattern, which records the ids of messages already handled and skips repeats.

Figure 2: With the outbox, the message survives a broker outage and is delivered when the broker returns.

Comparing the approaches

ApproachCan lose a message?Can send a ghost message?Extra work
Save, then publishYes (broker down)NoNone
Publish, then saveNoYes (save fails)None
Outbox PatternNoNoSmall (table + worker)

The outbox is the only option in this table that is safe in both directions. That safety is why it is the standard, battle-tested choice for reliable messaging in microservices.

The outbox row's life, step by step

Each row in the outbox table moves through a few clear states, from the moment it is saved to the moment it is safely delivered:

Figure 3: The life of one outbox message — saved, picked up, published (with retries), and finally marked processed.

This table summarises what each state means and what the worker does:

StateMeaningWorker action
PendingSaved, not yet sentPick it up on the next loop
PublishingBeing sent to the brokerWait for confirmation
Pending (again)Send failedRetry with backoff
ProcessedBroker confirmedMark done, skip forever after

Best practices for production

A few simple habits keep your outbox fast and healthy:

  • Index the hot columns. Add an index on ProcessedOnUtc and OccurredOnUtc so the "find unsent messages" query stays instant even with millions of rows.
  • Process in batches. Read 20–100 messages at a time instead of one by one, to cut down database round-trips.
  • Add retries with backoff. If the broker keeps failing, wait a little longer between attempts (exponential backoff) so you do not hammer a struggling system.
  • Store a correlation id. Keeping a correlation id on each message makes it far easier to trace a request across services when something goes wrong.
  • Clean up old rows. Once messages are processed and a safe time has passed, archive or delete them so the table does not grow forever.
  • Use a library when you can. Tools like MassTransit and NServiceBus have the outbox built in, so you get a tested implementation instead of writing your own.

The complete picture: Outbox plus Inbox

The Outbox Pattern protects the sending side. But remember it gives at-least-once delivery, so the receiving side can get the same message twice. The natural partner that protects the receiver is the Inbox Pattern.

The inbox works as a mirror image of the outbox. When a service receives a message, before doing any work it first checks an InboxMessages table:

public async Task Handle(OrderPlaced message)
{
    // Have we already handled this exact message?
    bool alreadyHandled = await _db.InboxMessages
        .AnyAsync(x => x.MessageId == message.MessageId);
 
    if (alreadyHandled)
        return; // duplicate — safely ignore it
 
    // Record that we are handling it, then do the real work — in one transaction.
    _db.InboxMessages.Add(new InboxMessage { MessageId = message.MessageId });
    await DoTheActualWork(message);
    await _db.SaveChangesAsync();
}

Put together, the outbox and inbox form a complete, reliable pipeline:

The Full Reliable Messaging Pipeline

Service A Outbox
Broker
Service B Inbox
Safe Handling

Steps

1

Outbox

Service A saves data + message together, never loses it

2

Deliver

Worker publishes to the broker, with retries

3

Inbox

Service B checks if it already saw this message id

4

Handle once

Duplicate is ignored — work happens exactly once in effect

The outbox guarantees the message is never lost on the way out. The inbox guarantees it is never processed twice on the way in.

With both patterns in place, your message can survive a broker outage and a duplicate delivery — the two failures that most often corrupt event-driven systems.

When you should use it

Reach for the Outbox Pattern whenever losing a message would cause a real problem — payments, orders, sign-ups, anything where two services must stay in agreement. If you are doing event-driven communication between services and you save to a database first, the outbox is almost always the right call.

You can skip it for fire-and-forget messages where an occasional loss truly does not matter, like a non-critical "user viewed a page" analytics ping. But when correctness counts, the small cost of an outbox table buys you a system that never quietly loses data.

Quick recap

  • The dual-write problem: saving to a database and publishing to a broker are two separate transactions, so they can drift apart and lose or invent messages.
  • The Outbox Pattern writes your data and your message into the same database transaction, so they share one fate.
  • A background worker reads unsent messages and publishes them later, retrying until the broker confirms — so an outage never loses a message.
  • It gives at-least-once delivery, so make your consumers idempotent (often with the Inbox Pattern).
  • Index the outbox, process in batches, add retries, and consider a library like MassTransit.

Like the trusty outbox tray by your door, this pattern lets your app do its job in one clean step and trust that every message will be delivered — even if the postman is running late.

References and further reading

Official and foundational sources

Popular community articles

Related Patterns