System Design

Outbox Pattern: Guarantee DB-to-Broker Atomicity

A team I worked with had a recurring nightmare: the database would commit a transaction, but the event never made it to the message broker. Or worse, the...

24 Oct 2025

Outbox Pattern: Guarantee DB-to-Broker Atomicity

A team I worked with had a recurring nightmare: the database would commit a transaction, but the event never made it to the message broker. Or worse: the event published, but the database transaction rolled back. The data was out of sync. Downstream services had stale or phantom state. We spent nights firefighting.

The outbox pattern fixed it. Simple idea. Massive impact.

What the Outbox Pattern Does

It guarantees atomicity between your database write and your intent to publish an event. No distributed two-phase commit. No cross-service transactions. Just a pattern that uses your existing database as a staging area.

Here's what it buys you:

  • Atomicity between state change and publish intent. Write your domain state and an "outbox" record in the same DB transaction. Either both succeed or neither does.
  • Decoupled publishing. A separate process reads the outbox and sends events to the broker. If the broker is down, the message stays in the outbox. Retry later. No data loss.
  • Simpler failure handling. You get at-least-once delivery. Make consumers idempotent and you're covered.

The Core Mechanism

  1. In a single DB transaction, insert/update your business rows AND insert a new row in the outbox table with the event payload and metadata.
  2. Commit.
  3. A separate outbox-poller reads unprocessed outbox rows, publishes them to the broker, and marks them as published (or archives them).

That's it. The database transaction guarantees that if your business data is committed, the outbox row exists. The poller guarantees that the outbox row eventually reaches the broker.

The Outbox Poller

The poller is a long-running process. It wakes up on an interval, grabs a batch of unprocessed outbox rows, publishes them to the broker, and marks them done. Here is the core loop in Node.js:

Js
async function poll() {
  const client = await pool.connect()
  try {
    await client.query('BEGIN')

    const { rows } = await client.query(`
      SELECT * FROM outbox
      WHERE published_at IS NULL
      ORDER BY id
      LIMIT 100
      FOR UPDATE SKIP LOCKED
    `)

    for (const row of rows) {
      await producer.send({
        topic: row.topic,
        messages: [{ value: JSON.stringify(row.payload) }],
      })

      await client.query(
        'UPDATE outbox SET published_at = NOW() WHERE id = $1',
        [row.id]
      )
    }

    await client.query('COMMIT')
  } catch (err) {
    await client.query('ROLLBACK')
  } finally {
    client.release()
  }
}

// Polling loop
while (true) {
  await poll()
  await sleep(1000)
}

Publish first, mark done second. If the poller crashes after publishing but before marking done, the message gets sent twice. That is why consumers must be idempotent.

FOR UPDATE SKIP LOCKED is critical if you run multiple poller instances. Each instance grabs a different batch of rows, so you get parallelism without duplicates.

Running the Poller In-Process

The poller does not need to be a separate service. For most teams, running it as a background loop inside the same process is perfectly fine:

Js
// server.js
await app.listen({ port: 3000 })
startPoller() // background setInterval loop

The tradeoff comes when you scale horizontally. If you run ten API instances, ten pollers are competing on the same outbox rows. FOR UPDATE SKIP LOCKED keeps things correct, but it is wasteful. At that point, extract the poller into a dedicated process. Start in-process and split later when the need is real.

Using a Library

For most projects, pg-boss is the right choice. It uses PostgreSQL as its job queue backing store, handles FOR UPDATE SKIP LOCKED, retries, and exponential backoff out of the box:

Js
const boss = new PgBoss(process.env.DATABASE_URL)
await boss.start()

// Send inside your transaction
await boss.send('user-created', { userId: 123 })

// Worker
await boss.work('user-created', async (job) => {
  await kafka.send({ topic: job.name, messages: [{ value: JSON.stringify(job.data) }] })
})

Why not BullMQ? BullMQ is popular but it uses Redis as its backing store, not PostgreSQL. That breaks the atomicity guarantee. Your DB write and your queue insert become two separate operations to two separate systems: one can fail without the other. For the outbox pattern, you need a PostgreSQL-backed solution.

Transactional Outbox vs. CDC

I've used both approaches:

Transactional outbox: the application writes to an outbox table. A poller service reads and publishes. Low friction. Easy to understand. Works well when you control the app and the database.

CDC (Change Data Capture): tools like Debezium stream database changes directly into Kafka by reading the database's write-ahead log (WAL). No poller needed. Works well in polyglot environments where you can't easily change application code.

With CDC, Debezium tails the WAL the way you would tail -f a log file. Every insert to your outbox table is streamed to Kafka the moment it commits. Sub-millisecond latency, no polling overhead, no sleep intervals.

The catch is infrastructure. Debezium runs as a Kafka Connect process, which is an additional server. Your deployment goes from:

Text
API server
Postgres
Kafka

To:

Text
API server
Postgres
Kafka
Kafka Connect + Debezium   ← extra server
Zookeeper (or KRaft)       ← may be needed

That is real operational overhead. Most teams only reach for it when polling latency or DB load becomes a genuine problem.

When to Actually Use CDC

A single Postgres poller running every second handles thousands of events per minute comfortably. The query is cheap: an indexed scan on published_at IS NULL, grab 100 rows, done.

The signs that polling is becoming the bottleneck:

  • Poller query P99 latency is creeping up
  • The outbox table row count is growing (poller can't keep up with inserts)
  • DB CPU spikes are attributable to polling queries specifically

In practice you don't need CDC until you are handling tens of thousands of events per minute consistently. The typical progression is:

  1. In-process poller (simplest)
  2. Dedicated poller service (when you need to scale the API independently)
  3. Debezium and CDC (when polling load or latency becomes a real problem)

Most teams stop at step one or two and never need step three.

How Idempotency Keys Work

The outbox guarantees at-least-once delivery. If the poller publishes a message and then crashes before marking it done, the same message gets published again on the next poll. Consumers will see duplicates. Idempotency keys are how you handle that safely.

The key is a unique identifier attached to each message, usually the UUID of the outbox row. When a consumer receives a message, it checks whether it has already processed that key. If yes, it skips. If no, it processes and records the key.

A simple implementation with a dedicated table:

Sql
CREATE TABLE processed_messages (
  idempotency_key TEXT PRIMARY KEY,
  processed_at    TIMESTAMPTZ DEFAULT NOW()
);

Consumer logic:

Js
async function handleMessage(message) {
  const key = message.idempotencyKey

  const already = await db.query(
    'SELECT 1 FROM processed_messages WHERE idempotency_key = $1',
    [key]
  )
  if (already.rows.length > 0) return // duplicate, skip

  await db.query('BEGIN')
  // ... do the actual work ...
  await db.query(
    'INSERT INTO processed_messages (idempotency_key) VALUES ($1)',
    [key]
  )
  await db.query('COMMIT')
}

The work and the key recording happen in the same transaction. If anything fails, neither commits, so the next delivery attempt processes it correctly.

Alternative: lean on a unique constraint. If your business operation naturally produces a unique row (e.g. one order_confirmed event per order), a unique constraint on the business table rejects the duplicate insert. The constraint itself acts as the idempotency guard, and you don't need a separate tracking table.

Lessons I Learned the Hard Way

Make consumers idempotent. The outbox guarantees at-least-once delivery, not exactly-once. Consumers will see duplicates. Design accordingly: use idempotency keys, unique constraints, or check-before-write patterns.

Keep outbox payloads small. We once stored 100KB messages in the outbox. Replication and backup costs spiked. Store small payloads, or reference external blobs (S3 keys) for large data.

Index for efficient polling. Add an index on (published_at, created_at). We had a poller causing full table scans until we added the right index. That's a painful way to learn.

Be explicit about ordering. If you need strict ordering, you need a partitioning key and sequence number. The outbox alone doesn't guarantee order across different aggregates.

Monitor publish lag. The message is durable in the database, but if your poller falls behind, you have a growing backlog. Alert on outbox table size and publish lag.

Handle poison messages. Some payloads will always fail publishing or always crash consumers. Move them to a dead-letter table after N attempts. Don't let one bad message block the entire queue.

Implementation Details Worth Knowing

FOR UPDATE SKIP LOCKED: this SQL clause lets multiple poller instances run in parallel without stepping on each other. Each poller locks and processes a different set of rows. Essential for horizontal scaling of the poller.

attempts column: track how many times you've tried to publish each row. After N failed attempts, move to a dead-letter table for manual investigation.

published_at vs. delete: I prefer marking published_at instead of deleting immediately. It helps with auditing and troubleshooting. Archive older rows asynchronously on a schedule.

Table partitioning: if you have millions of outbox rows, partition by time or by aggregate type. Keeps query performance predictable and makes archival simple.

When to Use the Transactional Outbox

Use it when:

  • You control both the application and the database.
  • You want the simplest guarantee that the event is tied to the DB transaction.
  • You're building a new service and can design the schema from scratch.
  • Your team is small and you don't want to operate CDC infrastructure.

Pre-Ship Checklist

Before deploying an outbox to production:

  • DB transaction encompasses both domain write and outbox insert.
  • Index on (published_at, created_at) and optionally (aggregate_id).
  • Consumer idempotency implemented (idempotency keys, unique constraints).
  • Poller lag and outbox size monitored with alerts.
  • Poison message handling (attempts counter + DLQ).
  • Payloads kept small or large data stored externally.
  • GDPR considered: can you purge personal data from outbox rows when required?

The Bottom Line

The outbox pattern isn't glamorous. It won't impress anyone at a conference talk. But it's one of those engineering practices that earns its keep every day. It forces you to acknowledge the reality of distributed systems: side effects can fail independently, and this pattern gives you a pragmatic, testable way to make your system more reliable.

I reach for it whenever I need to guarantee that a database write and an event publication happen together. It's simple, it's proven, and it works.