Distributed Patterns

Outbox Pattern

One transaction, two writes — without a distributed transaction

The outbox pattern writes a domain event to a same-database outbox table inside the business transaction. A separate poller publishes those rows to the message bus. No dual-write window.

  • Atomicity boundarySingle local ACID transaction
  • Delivery semanticAt-least-once (consumers dedupe)
  • Publish latency (poll)~50–200 ms typical
  • Publish latency (CDC)~5–50 ms typical
  • Cost vs 2PCNo XA, no coordinator
  • Used inDebezium, Eventuate, Axon, custom

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

How the outbox pattern works

A service has to do two things at once. Persist an order row to its database. Publish an OrderPlaced event to Kafka so the warehouse, the billing service, and the analytics pipeline all see the new order. The naive code looks innocent:

db.insert(order)              // (1) save to DB
kafka.publish("OrderPlaced", order)   // (2) send event

Both lines look like one operation. They are not. Step 1 runs in a Postgres transaction. Step 2 is a TCP call to a Kafka broker. Nothing ties their outcomes together. If the process crashes between them, you have an order with no event — the warehouse never ships it. If step 1 is rolled back after step 2, you have an event for a row that doesn't exist — downstream systems will create ghost work. This is the dual-write problem, and it bites every event-driven architecture that ignores it.

The outbox pattern fixes it with a single rule: both writes happen inside the same database transaction. The event goes into an outbox table next to the order row:

BEGIN;
  INSERT INTO orders        (id, …, total) VALUES (…);
  INSERT INTO outbox_events (aggregate_id, type, payload, occurred_at)
                            VALUES (order.id, 'OrderPlaced', '{…}', NOW());
COMMIT;

One transaction, one COMMIT. Either both rows land, or neither does. The dual-write window is gone. Now a separate poller reads from outbox_events and publishes each row to Kafka. If the poller crashes, the row stays in the outbox and is published on the next run. If the broker is down, the poller retries. Nothing is lost.

Polling vs CDC-driven outbox

Two flavors of poller exist. The simpler one is a SQL polling loop: every 100 ms or so, SELECT * FROM outbox_events WHERE delivered_at IS NULL ORDER BY id LIMIT 100, publish each row, then update or delete. Throughput is bounded by polling frequency and database load. Latency averages half the poll interval.

The fast variant uses change data capture. Debezium tails the Postgres WAL or MySQL binlog, recognizes inserts into the outbox table, and pushes events to Kafka in milliseconds. No polling. No SELECT load on the primary. The trade-off is operational complexity: a Debezium connector adds a deployable component and the WAL/binlog must be retained until Debezium has consumed it.

For most teams under 10k events/second the polling outbox is enough. Past that, CDC-driven becomes the pragmatic answer.

When to use the outbox pattern

  • You write to a relational or document database and publish events to a message bus from the same handler.
  • You need eventual consistency between the database state and the event stream, with no possibility of one without the other.
  • You can't or won't run XA / 2PC across DB and broker (which is essentially every production system today).
  • You're building event-driven services, CQRS, event sourcing, or any saga where downstream side-effects must mirror your local commits.

You don't need the outbox if you only ever write to the database (no events), or if you only ever publish events (no DB), or if you can model the entire workflow as event-sourced where the event log is the database.

Outbox vs 2PC vs naive dual-write vs change data capture

Outbox patternNaive dual-write2PC / XADirect CDC
AtomicityYes (one local TX)No (two networks)Yes (distributed)Yes (DB is source of truth)
Lost-event riskNoneHighNoneNone
Phantom-event riskNoneHighNoneNone
Delivery semanticAt-least-onceBest effortExactly-onceAt-least-once
Latency50–500 ms (poll) / 5–50 ms (CDC)~ms10–100 ms blocking5–50 ms typical
Operational complexityLow (poller) / Med (Debezium)MinimalHigh (XA coordinator)Med-High
Schema couplingDecoupled (explicit event)DecoupledDecoupledCoupled to row schema
Broker support requiredAnyAnyXA-capable (rare for Kafka)Any

Outbox dominates in real systems because it gets correctness for free from a single ACID write, without depending on the message broker to participate in distributed transactions — something Kafka famously refuses to do safely.

What the outbox costs

Storage: each event row is typically a few KB of JSON in the outbox table. At 1k events/sec retained for 24 hours, that's 86 million rows — manageable on Postgres with a small index on (delivered_at, id) and an hourly cleanup. Plan for ~1–10 GB of outbox storage per million events retained.

Write amplification: every event-producing transaction now writes one extra row. On a busy OLTP workload that's a 5–15% throughput hit on commit volume, mostly invisible until the outbox table itself starts dominating I/O.

Publish latency: the poll-interval lag is unavoidable in the polling variant. Tuning the interval below 50 ms creates serious SELECT pressure on the primary. Move to CDC-driven if you need sub-50 ms.

Re-publication on crash: at-least-once means duplicates. Consumers must be idempotent. Dedup by event ID using a Redis set with a short TTL, or a "seen IDs" Postgres table on the consumer side. Without idempotent consumers the outbox is only as safe as the dedup downstream.

Pseudo-code

// Producer-side: one transaction wraps the business write and the event.

placeOrder(req):
    BEGIN
      order = orders.insert(req)
      outbox.insert({
        id: uuid(),
        aggregate_id: order.id,
        type: 'OrderPlaced',
        payload: serialize(order),
        occurred_at: now(),
        delivered_at: NULL,
      })
    COMMIT
    return order

// Background poller — runs every 100ms.

poll():
    rows = SELECT * FROM outbox
           WHERE delivered_at IS NULL
           ORDER BY id ASC
           LIMIT 200
           FOR UPDATE SKIP LOCKED
    for row in rows:
        try:
            kafka.publish(
              topic   = row.type,
              key     = row.aggregate_id,   // ordering by aggregate
              headers = { 'event-id': row.id },
              value   = row.payload,
            )
            UPDATE outbox SET delivered_at = now() WHERE id = row.id
        except KafkaTimeout:
            // Leave delivered_at NULL — retry next poll
            continue

The FOR UPDATE SKIP LOCKED is the trick that lets multiple poller instances run in parallel without stepping on each other — each poller grabs a disjoint batch.

Python: a complete Postgres + Kafka outbox

import json, uuid, psycopg2, time
from confluent_kafka import Producer

OUTBOX_SQL = """
CREATE TABLE IF NOT EXISTS outbox (
  id            UUID PRIMARY KEY,
  aggregate_id  TEXT NOT NULL,
  type          TEXT NOT NULL,
  payload       JSONB NOT NULL,
  occurred_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  delivered_at  TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS outbox_undelivered_idx
  ON outbox (id) WHERE delivered_at IS NULL;
"""

def place_order(conn, order):
    """Business write + event in one transaction."""
    with conn:
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO orders (id, total) VALUES (%s, %s)",
                (order["id"], order["total"]),
            )
            cur.execute(
                "INSERT INTO outbox (id, aggregate_id, type, payload) "
                "VALUES (%s, %s, %s, %s)",
                (str(uuid.uuid4()), order["id"], "OrderPlaced",
                 json.dumps(order)),
            )

def poller(conn, producer):
    """Background loop — publish then mark delivered."""
    while True:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT id, aggregate_id, type, payload "
                "FROM outbox WHERE delivered_at IS NULL "
                "ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED"
            )
            rows = cur.fetchall()
            for eid, agg_id, typ, payload in rows:
                producer.produce(
                    topic=typ,
                    key=agg_id,
                    value=json.dumps(payload),
                    headers={"event-id": str(eid)},
                )
                producer.flush()
                cur.execute(
                    "UPDATE outbox SET delivered_at = now() "
                    "WHERE id = %s", (eid,),
                )
            conn.commit()
        time.sleep(0.1)

Variants and extensions

  • CDC-driven outbox. Replace the poller with Debezium. Configure the outbox event router (io.debezium.transforms.outbox.EventRouter) to route each outbox row to the right Kafka topic based on its type column. Sub-50 ms latency, no poll load.
  • NOTIFY-driven outbox. Postgres LISTEN/NOTIFY can wake the poller immediately after a commit, so latency drops to a few ms without CDC infrastructure. Works for single-node pollers.
  • Aggregate-keyed partitioning. Use the aggregate ID as the Kafka message key. All events for the same order land on the same partition and stay in commit order, even with multiple parallel pollers.
  • Deduplication table on consumers. Each consumer keeps a (event_id, processed_at) table; an upsert with ON CONFLICT DO NOTHING rejects duplicates atomically with the business write.
  • Outbox cleanup. A nightly DELETE FROM outbox WHERE delivered_at < now() - INTERVAL '7 days' keeps the table small. Some teams partition by day and DROP PARTITION instead — much cheaper.
  • Inbox pattern (consumer side). Symmetric to outbox: consumers write the inbound event ID to an inbox table inside their business transaction, achieving idempotent end-to-end delivery.

Common pitfalls and edge cases

  • Forgetting SKIP LOCKED. Two pollers without FOR UPDATE SKIP LOCKED will serialize on the same rows. The system runs but throughput is capped at one poller's rate.
  • Publishing before commit. If you publish inside the transaction before COMMIT, a rollback leaves a phantom event in Kafka. The outbox row only becomes visible to the poller after the commit — that's the whole point.
  • Unbounded outbox growth. No cleanup job and no delivered_at partial index means the table grows linearly forever; eventually every SELECT pollers run becomes a sequential scan.
  • Mixing transactional and non-transactional writes. If part of your service writes to a different database that isn't covered by the outbox transaction, you've reintroduced the dual-write problem inside the same service.
  • Ordering assumptions across aggregates. Per-aggregate ordering is preserved by aggregate-keyed partitioning. Global ordering is not — and consumers that assume "events arrive in the order producers committed" will eventually be wrong.
  • Schema drift between outbox payload and consumer. The outbox payload is your wire contract. Use a schema registry (Avro, Protobuf) and version the event type, or you'll be debugging breakage in production six months from now.

Frequently asked questions

What's the dual-write problem the outbox pattern fixes?

When a service writes to a database AND publishes a Kafka event in the same handler, those are two independent network operations with no shared transaction. If the DB write succeeds and Kafka is down, you have data with no event. If Kafka succeeds and the DB rolls back, you have an event for data that doesn't exist. Either outcome corrupts the downstream world. The outbox folds both writes into one ACID boundary.

Why not just use a distributed transaction (2PC)?

Two-phase commit across a database and a message broker exists in theory and is broken in practice. Most modern brokers — Kafka especially — don't ship XA support that survives broker restarts cleanly. 2PC adds blocking holds across resources, inflates p99 latency by 5–10×, and creates a fragile coordinator dependency. The outbox pattern gives you the same correctness with one local transaction.

How does the poller deliver exactly-once?

It doesn't — the poller delivers at-least-once. Each outbox row carries a stable event ID; consumers dedupe by ID. If the poller crashes after publishing but before marking the row delivered, the row will be re-published — that's by design. Consumers must be idempotent. Pair the outbox with idempotent consumers and you get effectively-once end-to-end.

What's the typical end-to-end latency?

Polling-based outboxes add the poll interval: at 100 ms polling you average 50 ms of extra latency, with worst-case 100 ms plus the broker publish round-trip. CDC-based outboxes (Debezium streaming the WAL) shrink that to 5–50 ms typical because they react to commits in real time instead of polling. Most production systems land in the 50–500 ms range.

Should the outbox table grow forever?

No. The poller marks delivered rows and a separate cleanup job deletes them — typically retaining 1–7 days for replay and audit. Some teams use Postgres LISTEN/NOTIFY plus aggressive truncation; others run an hourly DELETE. The danger sign is the outbox growing faster than it shrinks — that means your poller can't keep up, and lag will eventually become a backlog.

How is this different from change data capture?

CDC streams every row change in your domain tables — automatic, but the schema of your DB becomes the schema of your events, which couples consumers to internal storage. The outbox pattern uses an explicit event table you control, decoupling event schema from row schema. Debezium can read from either; the choice is whether you want CDC-driven events shaped by your tables or outbox-driven events shaped by your domain.

Does the outbox preserve message ordering?

If the poller reads rows in insertion order (ORDER BY id) and publishes serially, yes — per partition. The standard trick is to use the aggregate ID as the Kafka partition key, so all events for the same order, user, or entity stay in order on a single Kafka partition. Cross-aggregate ordering is not preserved, and you shouldn't depend on it.