Skip to content

Cloud & Infrastructure · Architecture Patterns

The Outbox Pattern: Publishing Events Without Losing Them

Writing to your database and publishing an event are two separate operations, and a crash between them silently loses data. The transactional outbox pattern closes that gap. Here's how it works and how to implement it.

Abhishek Gupta

Abhishek Gupta

5 min read

The Outbox Pattern: Publishing Events Without Losing Them

Sponsored

Share

Writing to your database and telling other services about it are two different operations against two different systems, and most codebases treat them as if they were one. They aren’t, and the gap between them is where events quietly disappear.

The gap, concretely

Say an order service saves a new order, then publishes an OrderCreated event so the shipping and notification services can react.

// The naive version
async function createOrder(orderData) {
  const order = await db.orders.insert(orderData);   // succeeds
  await messageBroker.publish('OrderCreated', order); // crashes here, or broker is down
  return order;
}

If the process crashes, gets killed by a deploy, or the broker connection times out between those two lines, the order exists in your database but nobody downstream ever hears about it. No exception bubbles up to the caller, because the database write already returned success. The order sits there, correctly saved, silently unshippable, until someone notices days later that a customer never got their confirmation email.

You can’t wrap the database write and the broker publish in one transaction, because they’re different systems. Postgres has no concept of a Kafka topic, and Kafka has no concept of a Postgres row. There is no BEGIN that spans both.

The outbox pattern’s actual mechanism

The fix is to stop treating the event as something that needs to reach the broker immediately, and instead treat it as data that needs to reach your own database immediately, in the same transaction as the business write. A separate, independent process is then responsible for getting it from your database to the broker.

CREATE TABLE outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type TEXT NOT NULL,
  aggregate_id TEXT NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at TIMESTAMPTZ
);
// The outbox version
async function createOrder(orderData) {
  return db.transaction(async (tx) => {
    const order = await tx.orders.insert(orderData);
    await tx.outbox.insert({
      aggregate_type: 'Order',
      aggregate_id: order.id,
      event_type: 'OrderCreated',
      payload: order,
    });
    return order; // both rows commit together, or neither does
  });
}

Both inserts are ordinary rows in the same database, in the same local transaction. Postgres (or whatever your database is) already guarantees atomicity within a single transaction, so this part requires no new infrastructure. Either both the order and its outbox row exist, or neither does. There is no window where the order is saved but the event is unrecorded.

The relay: getting rows out of the outbox

The outbox table isn’t the finish line, it’s a durable staging area. Something still has to move those rows to the actual message broker so other services can consume them. Two common approaches:

Polling relay. A background worker queries for unprocessed rows on an interval, publishes each to the broker, and marks it processed.

async function relayOutboxEvents() {
  const rows = await db.outbox.findUnprocessed({ limit: 100 });
  for (const row of rows) {
    await messageBroker.publish(row.event_type, row.payload);
    await db.outbox.markProcessed(row.id);
  }
}

setInterval(relayOutboxEvents, 500); // poll every 500ms

Simple to build and reason about. The cost is latency (events wait up to one polling interval before going out) and repeated read load on the outbox table as it grows, which means you also need a retention job to prune processed rows.

Change data capture (CDC). A tool like Debezium reads the database’s write-ahead log directly and streams new outbox rows to the broker as they’re written, without polling the table at all.

Diagram showing a service writing to its database in one transaction, then a CDC connector tailing the write-ahead log and forwarding outbox rows to a message broker, which fans out to downstream consumer services

CDC gets you much lower latency and removes the polling overhead entirely, at the cost of running and operating another piece of infrastructure (the CDC connector) and a bit more operational surface area to monitor.

Most teams start with a polling relay because it needs nothing beyond application code, and move to CDC once event latency or database load from polling becomes a real, measured problem, not a hypothetical one.

What the outbox pattern doesn’t give you

Not exactly-once delivery. If the relay publishes an event and then crashes before marking the row processed, it will publish that same event again on restart. The pattern gives you at-least-once delivery, which is a real and useful guarantee, but consumers still need to handle a duplicate arriving. That’s the same idempotency discipline covered in the idempotency key pattern for safe API retries: give each event a stable identifier and have consumers check whether they’ve already processed it before acting.

Not ordering across aggregates. Rows within the outbox for the same aggregate typically publish in insertion order, but if you’re relying on strict global ordering across every event type, that needs to be designed into your relay and broker setup explicitly, it isn’t free.

Not a replacement for the saga pattern. The outbox pattern solves reliably publishing one event from one service. A saga coordinates a multi-step business transaction across several services, often using events published this exact way as its underlying transport. The two patterns compose, they don’t compete.

When to skip it

If an occasionally-lost event is genuinely tolerable, an internal metrics ping, a non-critical analytics event, the naive publish-after-write approach is simpler and the outbox pattern is unnecessary complexity. Reach for it when a missed event has a real consequence: an order that never ships, a payment confirmation another service depends on, an inventory adjustment that silently drifts out of sync. That’s the class of problem the extra table and relay process are actually buying you protection against.

Frequently asked questions

What problem does the outbox pattern solve?
It solves the gap between writing to your own database and publishing an event about that write to a message broker or event stream. Those are two different systems with no shared transaction. If you write the database row and then the process crashes before the publish call, or the broker is down, the event is lost even though the business data was saved correctly. Consumers that were supposed to react to that event never find out it happened.
How is this different from just publishing the event directly after the database write?
Publishing directly means two round trips to two different systems with no atomicity between them. If the database commit succeeds but the app crashes, gets killed, or loses its network connection before the publish call completes, the event never goes out and nothing tells you it's missing. The outbox pattern removes that gap by writing the event as a row in your own database, in the same transaction as the business write, so the event's existence is guaranteed the moment the business data is guaranteed.
Does the outbox pattern guarantee exactly-once delivery?
No, it guarantees at-least-once delivery. If the relay process publishes an event and then crashes before it can mark the outbox row as processed, it will republish that same event on restart. Consumers need to be idempotent, able to safely handle receiving the same event twice, which is the same discipline required by the idempotency key pattern used for safe API retries.
What's the difference between a polling relay and change data capture?
A polling relay periodically queries the outbox table for unpublished rows, publishes them, and marks them done; it's simple to implement but adds latency equal to the polling interval and puts repeated load on the database. Change data capture (CDC) tools like Debezium tail the database's write-ahead log directly and stream new outbox rows to the broker with much lower latency and no polling overhead, at the cost of an extra piece of infrastructure to run and operate.
When is the outbox pattern overkill?
When losing an occasional event, or handling it manually when you notice it's missing, is genuinely acceptable for your use case. Internal analytics events where a small gap doesn't affect anything real often don't need this guarantee. The outbox pattern earns its complexity when a missed event means an order never ships, a payment never gets recorded downstream, or another service's state silently drifts out of sync with yours.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored