System Design

The Transactional Outbox Pattern: Guaranteed Event Publishing Without 2PC

There is a class of bug that is almost impossible to reproduce locally, surfaces only in production under load, and leaves your data in a state that is te...

27 Apr 2026

The Transactional Outbox Pattern: Guaranteed Event Publishing Without 2PC

There is a class of bug that is almost impossible to reproduce locally, surfaces only in production under load, and leaves your data in a state that is technically consistent but logically wrong. I have chased several of them. They almost always trace back to the same mistake: saving to a database and then publishing a message as two separate steps, with nothing guaranteeing both succeed or both fail.

The problem with "save, then publish"

Say you have an order service. When an order is placed, you write the order to Postgres and fire an order_placed event onto a Kafka topic. The consumer of that event is the inventory service, which reserves stock. Simple enough.

The failure modes are not obvious at first. The most common one: your application saves the order successfully, then crashes before it publishes to Kafka. The order exists in the database. The inventory service never heard about it. Now you have an order with no reserved stock, and no alarm rang.

You might think: what if I publish first, then save? That just inverts the problem. The event fires, inventory reserves the stock, then the database write fails. The order does not exist but the stock is held. Worse in some ways.

Some teams reach for two-phase commit to coordinate the two writes atomically. It works in theory. In practice, most message brokers do not support it well, and the ones that do pay a significant coordination cost. I have never seen 2PC used successfully in a high-throughput microservices setup. What I have seen is teams quietly abandon it after staging.

The outbox table

The transactional outbox pattern solves this with a simple idea: write the event to your own database, inside the same transaction as your business data. Then a separate process publishes those records to the message broker.

Your database becomes the source of truth for pending messages. The transaction either commits both the business row and the outbox row, or it rolls back both. There is no window where they can diverge.

The outbox table is usually straightforward:

Sql
CREATE TABLE outbox_events (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type VARCHAR(100) NOT NULL,
  aggregate_id   VARCHAR(100) NOT NULL,
  event_type     VARCHAR(100) NOT NULL,
  payload        JSONB        NOT NULL,
  created_at     TIMESTAMPTZ  DEFAULT NOW(),
  published_at   TIMESTAMPTZ
);

When the order service places an order, it does something like this inside a single transaction:

Sql
BEGIN;

INSERT INTO orders (id, customer_id, total, status)
VALUES ($1, $2, $3, 'pending');

INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
VALUES ('Order', $1, 'order_placed', $4);

COMMIT;

Either both rows land, or neither does. The delivery problem is now just about reading from outbox_events and publishing.

The relay: polling vs CDC

There are two ways to get events out of the outbox table.

Polling is the simpler one. A background job runs on an interval, queries for unpublished events, publishes each to the broker, and marks them as done. It works. The downsides are latency (you are at the mercy of your poll interval) and extra load on the database.

Ts
async function processOutbox(db: Pool, kafka: Producer) {
  const { rows } = await db.query(
    `SELECT * FROM outbox_events WHERE published_at IS NULL ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED`
  )

  for (const row of rows) {
    await kafka.send({
      topic: row.aggregate_type.toLowerCase() + '-events',
      messages: [{ key: row.aggregate_id, value: JSON.stringify(row.payload) }],
    })
    await db.query(
      `UPDATE outbox_events SET published_at = NOW() WHERE id = $1`,
      [row.id]
    )
  }
}

The FOR UPDATE SKIP LOCKED is important if you run multiple relay instances. It prevents two workers from picking up the same rows.

Change Data Capture (CDC) is more elegant. Tools like Debezium connect to the database transaction log (the write-ahead log in Postgres, the binlog in MySQL) and stream changes in near real-time. You get sub-second latency, no polling overhead, and no extra queries. The trade-off is operational complexity: you now have Kafka Connect or a standalone Debezium instance to maintain, and you need to make sure the replication slot does not fall too far behind.

For most teams I would start with polling. It is boring, visible, and requires almost nothing beyond what you already have. Move to CDC if latency becomes a real issue or if the polling load on your database is measurable.

At-least-once delivery

The outbox pattern gives you at-least-once delivery, not exactly-once. If the relay publishes an event and then crashes before marking it as done, it will publish again on restart. This is not a bug in the pattern; it is a known property you have to design for.

Your consumers need to be idempotent. That usually means tracking which event IDs they have already processed:

Sql
CREATE TABLE processed_events (
  event_id   UUID PRIMARY KEY,
  processed_at TIMESTAMPTZ DEFAULT NOW()
);

Before acting on an event, check if it is already in this table. Insert it inside the same transaction as whatever the consumer does. Duplicate events become a no-op.

This sounds like extra work. But you should be building idempotent consumers anyway. Network retries, broker redeliveries, and consumer restarts all produce duplicates regardless of whether you use the outbox pattern. Any consumer that cannot handle the same message twice is fragile.

Housekeeping

The outbox table will grow without bound if you do not clean it up. After events are published, they have no ongoing purpose. A simple scheduled job that deletes rows older than a few days is usually enough:

Sql
DELETE FROM outbox_events
WHERE published_at IS NOT NULL
AND published_at < NOW() - INTERVAL '3 days';

If you want an audit trail, write published events to a separate archive table or to object storage before deleting, rather than keeping them in the hot outbox.

What this pattern does not solve

The outbox pattern handles the gap between a database write and a message publish. It does not help you if the problem is upstream. If a request comes in, the database write fails, and you want to notify the caller that the operation failed, the outbox is irrelevant. That is regular error handling.

It also does not replace a saga or a process manager for multi-step workflows. If placing an order requires writes across multiple services, the outbox ensures each service reliably publishes its own events, but you still need to think about compensation if a later step fails.

And if your message broker goes down for an extended period, your outbox table will fill with unpublished events. That is not a bad thing; it is exactly the right behaviour. But you should have alerting on the lag between created_at and published_at so you know when the relay is stuck.

Worth doing

The pattern has a reputation for being complex. I do not think that is fair. The outbox table is ten lines of SQL. The polling relay is maybe fifty lines of code. The hard part is recognising when you need it.

If you are in a monolith talking to a single database and not publishing events anywhere, you probably do not need it. If you are running microservices, using event-driven communication, and care about consistency, it is almost always the right choice over fire-and-forget publishes. The failures it prevents are exactly the kind that are hard to debug, hard to recover from manually, and easy to miss in testing.

One thing I am still not sure about: the right home for the relay. Should it live in the same deployment as the service that owns the outbox, or as a shared infrastructure component that fans out to any service's outbox? I have seen both work. The shared approach is more efficient but creates a dependency that slows down independent service deployments. The per-service approach duplicates code but keeps teams autonomous. I lean toward per-service, but it is a real trade-off worth discussing with your team before you build it.


If you want to go deeper on distributed systems patterns like this, I cover them in detail on YouTube (@gazarbreakpoint) and in Monday BY Gazar where I send one system design breakdown per week.

Keep reading