The Transactional Outbox: Commit the Event with the Data
By Sergey Nosov
16 October 2026
These three lines save an order and announce it to the rest of the system:
db.Orders.Add(order);
await db.SaveChangesAsync();
await bus.PublishAsync(new OrderCreated(order.Id, order.Total));
They pass the usual tests and hide a bug that those tests never exercise. The order goes to the database, and the event goes to a message broker. Those are two systems, and no transaction spans them. Between the commit and the publish there is a gap a few milliseconds wide, and it opens on every request. It is the bug I find most often when I read event-driven code, and it has a name: the dual-write problem.
The fix is to stop writing to two systems. Write the event into the same database, in the same transaction as the order, and let a separate process publish it afterward. That is the transactional outbox. This article builds the outbox and its relay in C# with Entity Framework Core, compares polling with change data capture, and turns to the consumer, which has to absorb duplicates. The samples were tested on .NET 10 with EF Core 10 against SQL Server 2019.
Three Ways Two Writes Disagree
The commit succeeds, and the publish fails. The broker is unreachable for a second, a deployment stops the process, or a network link drops. The order exists, and nobody hears about it: no payment, no shipment, no confirmation email, no entry in the search index. Particular Software, the maker of NServiceBus, calls such an order a “zombie record.”
If the process dies between the two lines, nothing logs an error and nothing ever retries, because the event was never written down anywhere. If the publish throws instead, the caller gets an error for an order that was saved. A retry can then create a second order, unless the request carries an idempotency key.
The publish succeeds, and the commit fails. This happens when the code publishes first, or from inside an open transaction. A constraint violation, a deadlock, or a timeout can then roll the order back after the event has gone out. Every consumer acts on an order that does not exist: the payment service charges a card for it, and the warehouse reserves stock for it. Particular calls that event a “ghost message.”
Both succeed, in the wrong order. In a 2015 talk on dual writes, Martin Kleppmann showed two clients writing different values for the same key to two datastores. The first datastore receives A, then B; the second receives B, then A. Nothing failed, yet the stores now disagree, and “you probably won’t even notice that your database and your search indexes have gone out of sync, because no errors occurred.”
Events behave the same way. When two requests change the same order, their commits can land in one sequence and their publishes in the other. A consumer that keeps the latest state then ends up with the older one.
The same gap opens wherever a database write is followed by a write to anything else: a queue, a cache, a search index, an email server. Each call has only a small chance of hitting it, but every call takes the chance. A one-second broker outage catches every request in flight at once. It is rare per call and, at any real volume, close to certain per year.
Fixes That Do Not Fix
Four fixes come up first, usually in this order:
- Publish first. That trades a lost event for a phantom one. It is the same bug with the steps swapped.
- Retry the publish. Retry inside what? The process can die between the commit and the first retry, and the retry’s state lives in the memory that just died. A retry loop narrows the window; it does not close it.
- Use a distributed transaction. In two-phase commit, a participant that has prepared holds its locks until the coordinator sends the outcome, and stays blocked if the coordinator fails. Many brokers cannot take part at all. Azure Service Bus transactions cover Service Bus entities only. An accepted proposal, KIP-939, would let a Kafka producer take part in a two-phase commit run by an external coordinator. The Kafka 4.3 producer API does not yet include its prepare and complete calls. Even where both sides could join, Chris Richardson, author of Microservices Patterns, finds coupling the service to both “often undesirable.”
- Be careful. The window is milliseconds wide and opens millions of times. Care does not scale; design does.
Since you usually cannot make one transaction span both systems, write to only one of them and let it drive the other. Gunnar Morling, who led the Debezium project, gives this rule in a 2019 post on the outbox pattern. A service can write only to the broker and update its database from its own event. Then, as Morling points out, it loses “read your own write” semantics: a customer who places an order may not find it in the order history yet. Writing only to the database avoids that, and the outbox is how the database drives the broker.
The Outbox: One Commit for the Order and Its Event
An outbox is a table in the service’s own database. Each row is a message that the service owes the rest of the system:
public class OutboxMessage
{
// Database-assigned: the relay
// publishes in this order
public long Id { get; set; }
// Travels with the message;
// consumers deduplicate on it
public Guid MessageId { get; set; }
// The order it belongs to: the
// broker's partition key
public Guid AggregateId { get; set; }
public string Type { get; set; } = "";
public string Payload { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime? PublishedAt { get; set; }
}
Placing an order now adds two rows to the same unit of work and saves once. The method no longer takes a broker at all:
async Task PlaceOrderAsync(OrderDb db, Order order)
{
db.Orders.Add(order);
db.Outbox.Add(new OutboxMessage
{
MessageId = Guid.NewGuid(),
AggregateId = order.Id,
Type = nameof(OrderCreated),
Payload = JsonSerializer.Serialize(
new OrderCreated(order.Id, order.Total)),
CreatedAt = DateTime.UtcNow,
});
// One transaction: both rows,
// or neither
await db.SaveChangesAsync();
}
Microsoft’s documentation for EF Core states the guarantee: “By default, if the database provider supports transactions, all changes in a single call to SaveChanges are applied in a transaction.” Both rows are committed, or neither is. Give the order an ID that already exists, and SaveChangesAsync throws without leaving an outbox row behind.
Nothing has been published yet, and that is the point. The request path never talks to the broker. A relay reads the row later and publishes it. The request path owns the database, the relay owns the broker, and the outbox row is the contract between them. A broker outage no longer fails requests, either: orders keep committing, and their events wait in the table until the broker is back.
Four details in the entity matter later:
- The database assigns
Id, and the relay publishes in that order. A timestamp from the application server would be worse, because two servers’ clocks can disagree by more than the time between two events. MessageIdidentifies the message itself. It travels to the broker with the payload, and every consumer deduplicates on it. Do not use the numericIdfor that; every producer’s outbox has its own row 42.AggregateIdnames the order that the event belongs to. The broker gets it as the key that keeps each order’s events together.- The payload is serialized when the order is saved, so it describes the order as it was at that commit, not as it is when the relay gets to it. Treat
TypeandPayloadas a contract: a backlog can outlive a deployment, andnameofties the message type to a C# class name.
The Relay Publishes, Then Marks
The relay is deliberately boring. It runs on a timer with a new DbContext for each pass, reads a batch of unpublished rows in order, publishes each one, and marks it published. The IBroker interface stands for your broker’s client, with one requirement in its comment. Its key parameter carries the order’s ID, for use as a partition key or session ID:
public interface IBroker
{
// Must not complete until the
// broker has acknowledged it
Task PublishAsync(
string type, string payload, Guid messageId, Guid key);
}
// Runs in a background worker on a
// timer, never in the request path
async Task PublishPendingAsync(OrderDb db, IBroker broker)
{
var batch = await db.Outbox
.Where(m => m.PublishedAt == null)
.OrderBy(m => m.Id)
.Take(100)
.ToListAsync();
foreach (var message in batch)
{
await broker.PublishAsync(message.Type, message.Payload,
message.MessageId, message.AggregateId);
// A crash here means the
// next pass sends it again
message.PublishedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
Everything depends on the sequence of the last two steps. The relay marks a row only after the broker acknowledges it. If the process dies in between, the row is still unpublished when the relay restarts, so the message goes out again. That is not a bug in the relay; it is the guarantee. A crash can produce a duplicate, never a loss. This is at-least-once delivery.
“Acknowledged” has to mean stored. With RabbitMQ, that takes publisher confirms, and for persistent messages routed to durable queues, the documentation says, the acknowledgment “means persisting to disk.” RabbitMQ also confirms a message that reached no queue. Publish with the mandatory flag to have such a message returned, and treat it as a failed publish. With Kafka, keep the producer’s acks at all, the default, and raise the topic’s min.insync.replicas from 1 to at least 2.
When a publish fails, the exception ends the pass. The rest of the batch waits for the next pass instead of overtaking the failed message, which keeps the sequence. Catch and log the exception in the worker, and back off before the next pass. Since .NET 6, an exception that escapes a BackgroundService stops the host by default, so a relay running inside a web application would take the application down with the broker.
Stopping at the first failure has a cost. A message that can never be published, such as one larger than the broker accepts, blocks everything behind it. Count the attempts, and after a limit move the row to a dead-letter table and raise an alert: that order’s later events will go out without it.
The relay asks for every unpublished row on each pass, not for the rows after the last one it saw. With concurrent writers, one transaction can commit row 8 before another commits row 7. PostgreSQL reads committed row versions without waiting, and so does SQL Server with READ_COMMITTED_SNAPSHOT on, which is the default in Azure SQL Database. A relay on either sees 8 first, and one that kept a position would never send 7; my article on CQRS walks through that gap. Asking for unpublished rows sends such a straggler late instead of losing it.
With the setting off, which is SQL Server’s default, the relay’s query waits instead for the transaction that wrote row 7 to end. One slow transaction then holds up every event behind it.
Within one order, a version number rules stragglers out, as long as a concurrency check guards it. Put the version in each event and increment it in the same transaction. Two transactions on one order then cannot overlap and both commit, so a straggler always belongs to another order, and one relay publishes each order’s events in sequence. In a web service, a background worker runs in every replica, so run the relay in its own process or only in the replica that holds a database lock.
If one relay cannot keep up, competing relays must claim rows so that no two publish the same row. The tools are FOR UPDATE SKIP LOCKED in PostgreSQL and the UPDLOCK and READPAST hints together in SQL Server. Both hold a claim only until the relay’s transaction ends, so that transaction stays open through every publish. A lease column, set and committed before publishing, works in any database. Partitioning keeps the sequence that competing relays lose: give each relay a share of the orders by a hash of AggregateId, so that each order’s events stay with one relay.
Competing relays, a dead-lettered row, or a second relay during a deployment or after a lost lock can still reorder events, so consumers check the version. Each records it with its own state, skips older versions when events carry the full state, and holds back an event that follows a gap when they do not.
Polling or Change Data Capture
The relay above is what Richardson calls a polling publisher. The alternative, transaction log tailing, reads the database’s own log of committed changes and publishes each outbox insert it finds there.
A polling relay works with any SQL database. Its costs are latency, since an event can wait a full polling interval, and load, since every pass is a query. For most services, most of the time, it is enough.
Change data capture tools tail the log for you. Debezium, an open-source platform, has connectors for PostgreSQL, MySQL, SQL Server, Oracle, MongoDB, and other databases, and its outbox event router turns each new outbox row into a message. The relay then leaves your code entirely. On SQL Server, Debezium builds on the database’s own change data capture. A capture job scans the transaction log into change tables, waiting 5 seconds between scans by default, and the connector polls those tables. Log tailing means more infrastructure to run, and each database needs its own solution.
Change data capture can also publish changes to your business tables directly, with no outbox at all. Consumers then receive row changes shaped like your tables, and every schema change becomes theirs as well. Tailing only an outbox keeps the contract in your hands.
Change data capture does not end duplicates. Debezium’s documentation says that “Debezium provides at-least-once delivery guarantees.” Its exactly-once option works through Kafka Connect and covers only the writes into Kafka.
Log tailing does allow one trick that polling cannot use. Because the relay reads the log, not the table, the service can insert the outbox row and delete it in the same transaction. Morling’s example does exactly that, so its outbox table is always empty. The log still holds the insert, so the event is published, and no cleanup job is needed. But the table then keeps no copy, so an event written before capture starts is lost; set capture up before the service writes its first event.
The outbox event router expects inserts only. It “automatically filters out DELETE operations,” and its documentation says that “updates to records in an outbox table are not allowed.” So the PublishedAt column belongs to the polling relay alone.
The two approaches also fail differently. A stopped polling relay delays events while the outbox grows. A stopped log reader can fill the database server’s disk, or lose events. On PostgreSQL, a replication slot keeps every part of the log that its reader has not confirmed. PostgreSQL’s documentation warns that “replication slots can cause the server to retain so many WAL segments that they fill up the space allocated for pg_wal.” The setting that caps that retention, max_slot_wal_keep_size, defaults to no limit, and a slot that falls further behind than the cap may be unable to continue.
On SQL Server, the log is kept only until the capture job has copied the changes into change tables. A cleanup job then deletes change-table rows after 3 days by default, whether the connector has read them or not.
Azure Cosmos DB changes the shape of the pattern. Microsoft’s architecture guide uses a transactional batch, and it warns, “You can’t save two documents in a transactional batch operation in different containers or logical partitions.” Its reference implementation therefore stores each event as its own document in the business document’s container and logical partition, and saves both in one batch. Events derived from the business document would miss intermediate states in the change feed’s default latest-version mode. In Microsoft’s words, “if an item is created and then updated before you read the change feed, only the updated version appears.”
A change feed reader then publishes the events. It records its progress in a lease container, so the events need no published flag, and a time-to-live on each event document deletes it later. Set the time-to-live longer than any reader outage; the guide suggests multiple days. Latest-version mode does not capture deletes, so an event that expires unread is gone.
The guide calls an Azure Functions trigger the most convenient reader. By default, though, that trigger does not retry a batch whose function threw; it moves on without those events. Give the function a retry policy, and write what still fails to a dead-letter store of your own. On its first run, the trigger also starts at the current time, skipping events already written, unless StartFromBeginning is set.
The Consumer Keeps an Inbox
At-least-once delivery means duplicates, from the relay and from the broker itself, so every consumer has to tolerate them. Gregor Hohpe and Bobby Woolf named such a consumer the Idempotent Receiver in Enterprise Integration Patterns (2003). Richardson’s version is concrete: “Make a consumer idempotent by having it record the IDs of processed messages in the database.” That record is the inbox:
public class InboxEntry
{
[Key]
public Guid MessageId { get; set; }
public DateTime ReceivedAt { get; set; }
}
// The shipping service, with its
// own database. Returns false for
// a duplicate.
async Task<bool> HandleAsync(
ShippingDb db, Guid messageId, OrderCreated e)
{
await using var tx = await db.Database.BeginTransactionAsync();
// Claim the message ID first.
// A copy arriving meanwhile
// waits here for the first
// one to commit or roll back.
db.Inbox.Add(new InboxEntry
{
MessageId = messageId,
ReceivedAt = DateTime.UtcNow,
});
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsDuplicateKey(ex))
{
// Untrack the failed claim
db.ChangeTracker.Clear();
return false;
}
db.Shipments.Add(new Shipment { OrderId = e.OrderId });
await db.SaveChangesAsync();
// One commit for the claim
// and the work
await tx.CommitAsync();
return true;
}
// SQL Server duplicate-key errors
static bool IsDuplicateKey(DbUpdateException ex) =>
ex.InnerException is SqlException { Number: 2627 or 2601 };
The handler claims the message ID before it does any work, as Richardson’s handler does, and as my article on idempotency does with request keys. On PostgreSQL and SQL Server, if two copies arrive together, the second insert waits for the first transaction. PostgreSQL’s documentation states the rule: “If a conflicting row has been inserted by an as-yet-uncommitted transaction, the would-be inserter must wait to see if that transaction commits.” If the first copy commits, the second fails on the primary key and returns false. If the first rolls back, the second goes ahead and does the work.
Both outcomes held on SQL Server 2019 with READ_COMMITTED_SNAPSHOT off and on. Give each delivery a new DbContext, because SaveChangesAsync saves everything the context tracks. In a reused context, a leftover claim from a failed delivery would commit without its work, or fail on its key and make the next message look like a duplicate. The catch block clears the change tracker for the same reason.
A few rules keep the inbox honest:
- Acknowledge after the commit, duplicates included. If
HandleAsyncthrows, leave the message for redelivery, with a delivery limit and a dead-letter queue behind it. - On PostgreSQL, test for SQLSTATE 23505,
unique_violation, instead of SQL Server’s error numbers. - With EF Core’s
EnableRetryOnFailureon, this explicit transaction throws anInvalidOperationException, because the retrying strategy “does not support user-initiated transactions.” Microsoft’s documentation on connection resiliency shows the fix: run the handler through the strategy thatDatabasereturns, and create the.CreateExecutionStrategy() DbContextinside the retried delegate, as its example does. - Keep inbox rows longer than any copy can arrive late, whether through a relay backlog, a redelivery, or a replay.
- Key the inbox on the handler too if one database serves several handlers of the same message. Richardson’s table has “(subscriberId, messageID)” as its primary key.
The inbox covers only what happens inside the consumer’s transaction. If the handler also sends an email or calls a payment API, that is a second system again: the same dual-write problem, one hop downstream. Write the email, like any outgoing message, to the consumer’s own outbox in the same transaction, and give the payment API an idempotency key derived from the message ID.
Brokers can help, but they cannot replace the inbox. Azure Service Bus duplicate detection, in the Standard and Premium tiers, drops a message whose MessageId it has already seen within a window. The window is 10 minutes by default and 7 days at most. Send the outbox’s MessageId as the Service Bus message ID, and the broker catches relay duplicates inside that window.
Kafka’s exactly-once features cover reading, processing, and writing Kafka topics. For anything else, its documentation says, “Exactly-once delivery for other destination systems generally requires cooperation with such systems.” Your database is such a system, and the inbox is one form of that cooperation.
My Software Development Principles series has entries on the outbox, the transactional inbox, and change data capture, with the signs that each is applied well or badly.
Clean Up, Watch the Lag, and Keep the Sequence
Two jobs keep an outbox healthy:
// On a schedule: delete rows
// published more than a week ago,
// a thousand at a time
int deleted;
do
{
deleted = await db.Outbox
.Where(m => m.PublishedAt < DateTime.UtcNow.AddDays(-7))
.OrderBy(m => m.Id)
.Take(1000)
.ExecuteDeleteAsync();
}
while (deleted > 0);
// Lag: the age of the oldest
// unpublished row. Chart it
// and alert on it.
var oldest = await db.Outbox
.Where(m => m.PublishedAt == null)
.MinAsync(m => (DateTime?)m.CreatedAt);
var lag = oldest is null ? TimeSpan.Zero : DateTime.UtcNow - oldest.Value;
Clean up from day one. Published rows serve only debugging and audit. Delete them in batches: once one statement holds 5,000 locks, SQL Server tries to lock the whole table, and new orders would wait behind the delete. An outbox that keeps every event it ever sent also slows the relay’s query, unless an index covers only the unpublished rows. On SQL Server, a filtered index does that:
// In OnModelCreating: index only
// the rows the relay looks for
modelBuilder.Entity<OutboxMessage>()
.HasIndex(m => m.Id)
.HasFilter("[PublishedAt] IS NULL");
Watch the lag. The age of the oldest unpublished row is the delay the outbox adds; broker and consumer lag come on top. Chart that age and the number of unpublished rows, and alert on both; an outbox nobody watches is a queue nobody watches. A log-tailing relay never sets PublishedAt, so watch its own lag instead: the replication slot, the capture job, and the connector.
Keep each order’s events together. The relay’s sequence is lost if the broker spreads one order’s events across consumers. Debezium’s outbox event router uses the aggregate ID as the Kafka message key, which its documentation calls “important for maintaining correct order in Kafka partitions.” On Azure Service Bus, Microsoft’s documentation says to use sessions for first-in, first-out processing; set the session ID to the order ID.
Reach for a Library First
The relay, the inbox, and the cleanup are already written, and the libraries handle cases that this article skips. Check them before writing your own:
- MassTransit has a transactional outbox for EF Core. Its Bus Outbox serves code outside a consumer, such as an ASP.NET controller, and its Consumer Outbox “is a combination of an inbox and an outbox.” Mind the names: its In-Memory Outbox only buffers messages in the process, and “the buffer is lost if the process stops before it delivers the messages to the broker.” MassTransit became a commercial product with version 9; its NuGet page says it “must be licensed,” while earlier versions remain open source.
- NServiceBus, a commercial framework from Particular, has an outbox for message handlers and a Transactional Session package for sending from outside a handler, such as a web request.
- Debezium covers log tailing, and the change feed covers Azure Cosmos DB.
All of them are at-least-once underneath. MassTransit’s documentation says it directly: “Message brokers and distributed systems are at-least-once by nature.” Whichever you choose, consumers still deduplicate.
Try It on Your Own Code
Find one place in your code where a database write is followed by a publish, a cache write, an email, or an index update. Write down which ways it can fail—lost, phantom, or out of sequence—and whether anything would notice. Then ask whether losing that write would be a bug. Best-effort telemetry and a cache you can rebuild do not need an outbox. The outbox is for messages whose loss is a bug.
Checklist
- Never write to a database and a broker as two separate steps for a message whose loss is a bug. Write to one and let it drive the other.
- Put each event in an outbox row in the same transaction as the change, with a message ID that travels with it.
- Mark a row published only after the broker acknowledges it. Expect duplicates, never losses.
- Deduplicate in every consumer by claiming the message ID in the consumer’s own transaction, and acknowledge after the commit.
- Keep each order’s events in sequence: a version number under a concurrency check, one relay or partitioned relays, and a partition key or session on the broker. Consumers check the version.
- Delete published rows in batches on a schedule, and alert on the number of unpublished rows and the age of the oldest. If you tail the log, watch the slot, capture job, or connector instead.
- Check MassTransit, NServiceBus, Debezium, or your database’s change feed before writing a relay of your own.
Further Reading
- Pattern: Transactional Outbox (Chris Richardson, microservices.io)
- Reliable Microservices Data Exchange with the Outbox Pattern (Gunnar Morling, Debezium, 19 February 2019)
- Using Logs to Build a Solid Data Infrastructure (or: Why Dual Writes Are a Bad Idea) (Martin Kleppmann, Confluent, 29 May 2015)
- Implement the Transactional Outbox Pattern by Using Azure Cosmos DB (Microsoft, Azure Architecture Center)
- Outbox Event Router (Debezium documentation)
- Outbox (Particular Software, NServiceBus documentation)
- Idempotent Receiver (Gregor Hohpe and Bobby Woolf, Enterprise Integration Patterns)