The Transactional Outbox Pattern

How do you update a database and publish a message/event without risking inconsistency if one succeeds and the other fails?

The Transactional Outbox Pattern is a reliability pattern used in event-driven and microservice architectures to ensure that database updates and event publishing happen atomically.

Distributed systems rarely fail in obvious ways. They tend to fail in small, inconvenient, almost invisible ways. A timeout here. A dropped connection there. A process that exits at exactly the wrong moment. One of the most dangerous versions of this looks harmless at first:

  1. You update your database successfully.
  2. You attempt to publish an event.
  3. The publish fails.
  4. The process crashes.

From the outside, nothing dramatic has happened. There is no exception bubbling up to the user. There is no obvious corruption. And yet, your system has split into two different versions of reality. The database says the change happened but the rest of the world never heard about it.

This is how silent drops occur. An event that should have been emitted simply vanishes between a database commit and a message publish.

The Transactional Outbox pattern exists to close that gap.


The Problem

Consider a typical piece of application code:

// regular pattern
await db.SaveChangesAsync();
await messageBus.PublishAsync(new UserRegistered(user.Id));

The code is clean and readable. First persist the change. Then notify the rest of the system.

The problem is that those two operations live in different worlds.

The database commit is durable once it succeeds. The message publish depends on a network hop to a broker. If the commit succeeds and the publish fails, the user is registered in the database but no downstream service is aware of it.

There is no practical way to wrap a relational database and a message broker in a distributed two-phase commit without introducing serious complexity and fragility.

A different approach is needed.


The Solution

The key insight behind the Transactional Outbox pattern is simple: treat the event as data.

Instead of publishing directly to the message broker, the application writes the event into a dedicated “outbox” table as part of the same database transaction that performs the business change.

The flow becomes:

  1. Write your business change.
  2. Write an outbox row describing the event.
  3. Commit both within the same database transaction.
  4. A background process reads from the outbox and publishes events to the message broker.

If the transaction commits, both the business state and the event record are safely stored. If it rolls back, neither exists.

The database becomes the single source of truth for both state and pending messages.


A Simple Outbox Schema

An outbox table often looks something like this:

CREATE TABLE outbox (
    outbox_id UUID PRIMARY KEY,
    event_type VARCHAR(200) NOT NULL,
    aggregate_id UUID NOT NULL,
    payload JSON NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP NULL,
    publish_attempts INT NOT NULL DEFAULT 0
);

Each row represents an event waiting to be published. The presence of a row means the event exists. The published_at column indicates whether it has been successfully sent.


Writing to the Outbox

Application code changes slightly:

using var transaction = await db.Database.BeginTransactionAsync();

// whataever changes need to be made
db.Users.Add(user);

// add the outbox entry in the same transaction
db.Outbox.Add(new OutboxMessage
{
    OutboxId = Guid.NewGuid(),
    EventType = "UserRegistered",
    AggregateId = user.Id,
    Payload = JsonSerializer.Serialize(new { user.Id, user.Email })
});

await db.SaveChangesAsync();
await transaction.CommitAsync();

The transaction commits once. Both the user record and the outbox row are persisted together. If the process crashes before the commit, nothing is stored. If it crashes after the commit, both pieces of data remain and the system is internally consistent.

The Relay

Publishing happens asynchronously.

An asynchronous background worker periodically reads rows where published_at is null, publishes them to the message broker, and marks them as published.

var messages = db.Outbox
    .Where(x => x.PublishedAt == null)
    .OrderBy(x => x.CreatedAt)
    .Take(100)
    .ToList();

foreach (var message in messages)
{
    message.PublishAttempts++;
    PublishResult result = await messageBus.PublishAsync(message.Payload);
    if (result.Success)
		{
        message.PublishedAt = DateTime.UtcNow;
    }
}

await db.SaveChangesAsync();

If publishing fails, the row remains. The relay will attempt again later. The event is not lost because it already exists as durable data in the database.


What Happens During a Crash?

Different crash points produce different outcomes:

  • If the crash happens before the transaction commits, neither the business change nor the outbox row is stored.
  • If it happens after the commit but before publishing, the outbox row remains and will be picked up by the relay.
  • If it happens during publishing, the relay can retry based on the stored state.

The system may become eventually consistent, but it does not become inconsistent. The state and the intent to publish move forward together.


Idempotency Still Matters

The outbox guarantees that an event will be published. It does not guarantee that it will be published exactly once. A relay might retry. A network issue might cause duplicate delivery. Consumers must therefore be designed to handle at-least-once delivery semantics. Idempotency remains a requirement at the edges of the system.


Operational Considerations

The pattern introduces some overhead. There is an extra table. There must be a cleanup strategy. There is a slight delay between committing data and publishing the event. The relay requires monitoring.

Outbox rows are not permanent records. Common approaches include deleting rows after a retention window, partitioning by time, or archiving for audit purposes. These are manageable tradeoffs when compared to silent event loss.

The Transactional Outbox pattern is particularly useful in microservices, event-driven systems, domain event publishing, and any architecture where losing an event would leave the system in an incorrect state.

In a simple monolith without messaging, the pattern adds little value. In systems where eventual consistency is unnecessary, it may be overkill.

As always, context determines complexity.


Published 2026-02-24 >> 2026-03-10