Distributed Systems · Field Notes

The Outbox Pattern
The Silent Hero

It never trends. It never wins the architecture review on charisma. And it is holding up more production systems than most of the technology people actually brag about.

scroll

Every distributed system has its celebrities. Service meshes get conference keynotes. Kafka gets architecture diagrams drawn in reverent detail. Kubernetes gets its own job titles. But the component that quietly keeps your events from vanishing into the void — the one that lets an order survive a broker outage and a pod eviction on the same bad afternoon — rarely gets named. It just works, in the background, asking for nothing.

That's the Outbox pattern. It never trends. It never wins the design review on charisma. And it's holding up more production systems than most of the technology people actually brag about.

01 — THE CALL FOR HELPThe problem it steps into

Here's the situation every service eventually faces: it needs to update its own database and tell the rest of the world what happened. The order was placed. The payment cleared. The user signed up. Two things must happen — a local write and a message to a broker — and they must happen together, or the system starts lying to itself.

The naive version is the one everyone writes first:

OrderService.cs — the dual write
public async Task PlaceOrder(Order order)
{
    await _dbContext.Orders.AddAsync(order);
    await _dbContext.SaveChangesAsync();          // (1) DB commit

    await _serviceBus.PublishAsync(new OrderPlaced(order.Id)); // (2) broker publish
}

Two independent systems, two independent failure modes, no shared transaction. When step (1) succeeds and step (2) throws — a transient Service Bus timeout, a network blip, a pod killed mid-request — the database says the order exists, and the rest of the architecture never hears about it. No invoice, no fulfillment, no email. Flip the order and publish first, and you get the opposite ghost: an event announcing an order that was never saved.

You can't wrap a database transaction and a broker publish in one atomic operation. Distributed transactions technically can, but they're slow, brittle, poorly supported across cloud brokers, and nobody wants them in 2026. This is the exact moment the silent hero walks in — not with fanfare, but with a table.

Two systems. Two failure modes. And a window where your database quietly begins to lie.

02 — THE RESCUEWhat the hero actually does

Instead of writing to two systems, it writes to one. The outgoing message goes into the same database as the business data, inside the same transaction.

OrderService.cs — one atomic commit
using var tx = await _dbContext.Database.BeginTransactionAsync();

await _dbContext.Orders.AddAsync(order);

await _dbContext.OutboxMessages.AddAsync(new OutboxMessage
{
    Id          = Guid.NewGuid(),
    Type        = nameof(OrderPlaced),
    Payload     = JsonSerializer.Serialize(new OrderPlaced(order.Id)),
    OccurredOn  = DateTime.UtcNow,
    ProcessedOn = null
});

await _dbContext.SaveChangesAsync();
await tx.CommitAsync();

The business fact and the intent-to-publish now share a single atomic commit. Either both land or neither does. There is no window where one exists without the other. The impossible distributed-transaction problem has been quietly reduced to a single-database transaction — something every relational engine has solved for decades.

Service PlaceOrder() ONE TRANSACTION Orders table business fact Outbox table intent-to-publish Relay background loop Broker Service Bus
The service writes both rows in one commit. A separate relay drains the outbox to the broker — off the request path.

A separate process — the relay — reads unprocessed rows and publishes them, marking each one done on success.

OutboxRelay.cs — the quiet drain
public async Task RelayLoop(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        var pending = await _dbContext.OutboxMessages
            .Where(m => m.ProcessedOn == null)
            .OrderBy(m => m.OccurredOn)
            .Take(50)
            .ToListAsync(ct);

        foreach (var msg in pending)
        {
            await _serviceBus.PublishAsync(msg.Type, msg.Payload);
            msg.ProcessedOn = DateTime.UtcNow;
            await _dbContext.SaveChangesAsync(ct);
        }

        await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
    }
}

No drama. No exotic infrastructure. A table, a transaction, and a loop — doing the one job nobody else was willing to guarantee.

03 — THE POWERSHeroism you only notice when it's gone

The best measure of a silent hero is what doesn't happen once it's on duty. The Outbox doesn't just fix correctness; it quietly lifts load off every service it touches, and you feel its absence far more than its presence.

The request thread stops waiting on the broker. In the naive version, the request handling PlaceOrder is blocked until the broker acknowledges the publish. When the broker has a bad day, your API latency has a bad day. With the Outbox, the request commits locally and returns; publishing happens out of band. Your p99 stops being hostage to broker health — and nobody notices, because nothing broke.

Broker outages stop becoming service outages. If the broker is unreachable for ninety seconds, the naive service either fails requests or drops events. The Outbox service keeps accepting writes the whole time; messages pile up in the table and drain when the broker returns. The outage gets absorbed into the database you were already writing to.

BROKER DOWN recovery → drain writes keep succeeding → outbox backlog
The green writes never stop. The amber backlog swells safely in the table during the outage — then drains the moment the broker returns.

Retries leave the hot path. Transient publish failures are the relay's problem, retried against rows in the background. The customer already got their 201 Created. Redelivery is no longer something a user waits on.

Back-pressure comes for free. Under a traffic spike, the table grows and the relay drains it at a sustainable rate. The service soaks the burst into durable storage instead of melting while trying to publish synchronously at peak.

Add it up and the service does less work per request, holds threads for less time, and depends on fewer systems being healthy at the exact moment a user is waiting. The hero carries the weight so the service doesn't have to.

The systems that stay up on their worst days are usually the ones that remembered to keep it around.

04 — THE ONE RULEThe favor the hero asks

Every silent guardian has terms. The Outbox gives you at-least-once delivery, not exactly-once. If the relay publishes a message and crashes before writing ProcessedOn, it republishes on restart. That's a feature — it's why you never lose events — but it means consumers must be idempotent. Deduplicate on a message ID, use upserts, make the downstream operation naturally repeatable. Honor this one request and the hero never lets you down. Ignore it, and you'll ship duplicate invoices the first time a relay pod restarts mid-batch.

05 — THE PATROLHow the hero keeps watch

Polling is dead simple and runs anywhere: query for unprocessed rows on an interval. A little latency, a little steady querying — add an index on ProcessedOn and it scales further than you'd expect.

Change Data Capture tails the database transaction log instead of polling. On Azure, Debezium reading a SQL Server or PostgreSQL CDC feed into Event Hubs / Kafka is the common setup: lower latency, no polling load, more infrastructure to run. Call it in when event volume is high enough that polling latency actually shows up in your metrics — not before.

06 — THE GEARKeeping the hero in shape on Azure / Kubernetes

07 — KNOWING WHEN TO RESTWhen the hero should sit one out

Even silent heroes shouldn't show up uninvited. The Outbox is a table, a relay, cleanup, and a bit of operational surface. If a service publishes no events, or an occasional lost notification is genuinely harmless, skip it. The pattern earns its cape precisely when a local state change must be reflected downstream — orders, payments, provisioning, anything where a ghost record is a real business problem.

The unsung takeaway

The Outbox pattern will never be the reason someone picks your architecture in a design review. It has no dashboard worth screenshotting, no logo anyone frames. It just trades a hard, unsolvable distributed-transaction problem for an easy, solved single-database one — and then stands quietly on the request path, letting your services return fast, shrug off broker outages, and absorb traffic spikes into durable storage instead of into your users' latency.

That's the whole shape of a silent hero: doing the thankless, load-bearing work so reliably that everyone forgets it's there.