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.
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:
- Save the order to the database.
- Send an
OrderPlacedmessage 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.
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
Steps
Save together
Order and message saved in ONE transaction
Commit
Both succeed or both fail — never half
Poll
A background worker reads unsent messages
Publish
Send to RabbitMQ / Azure Service Bus, with retries
Mark done
Flag the row as processed so it is not sent twice
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.
Comparing the approaches
| Approach | Can lose a message? | Can send a ghost message? | Extra work |
|---|---|---|---|
| Save, then publish | Yes (broker down) | No | None |
| Publish, then save | No | Yes (save fails) | None |
| Outbox Pattern | No | No | Small (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:
This table summarises what each state means and what the worker does:
| State | Meaning | Worker action |
|---|---|---|
| Pending | Saved, not yet sent | Pick it up on the next loop |
| Publishing | Being sent to the broker | Wait for confirmation |
| Pending (again) | Send failed | Retry with backoff |
| Processed | Broker confirmed | Mark 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
ProcessedOnUtcandOccurredOnUtcso 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
Steps
Outbox
Service A saves data + message together, never loses it
Deliver
Worker publishes to the broker, with retries
Inbox
Service B checks if it already saw this message id
Handle once
Duplicate is ignored — work happens exactly once in effect
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
- Transactional Outbox pattern with Azure Cosmos DB — Microsoft Learn — Microsoft's own guidance and reference architecture.
- Pattern: Transactional Outbox — microservices.io — Chris Richardson's canonical write-up of the pattern.
Popular community articles
- Implementing the Outbox Pattern — Milan Jovanović — a well-loved .NET walkthrough.
- How to Build the Outbox Pattern in .NET — OneUptime — a recent, practical guide.
Related Patterns
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.
Building a Custom Domain Events Dispatcher in .NET (No MediatR Needed)
Build your own domain events dispatcher in .NET with EF Core. Simple analogy, full C# code, diagrams, and timing tips — no paid MediatR license required.
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.
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.
Implementing the Saga Pattern with Rebus and RabbitMQ in .NET
Learn the Saga pattern in .NET using Rebus and RabbitMQ with simple real-life examples, diagrams, correlation, compensation, and full C# code you can copy.
MassTransit Outbox Pattern with EF Core and MongoDB in .NET
Learn the transactional outbox pattern in .NET using MassTransit with EF Core and MongoDB so your database and message broker never fall out of sync.