Transactional Outbox Pattern

Updated

September 8, 2026

Transactional Outbox Pattern

Overview

If you UPDATE the DB then Publish to a queue, crashes create lost or double events. The outbox writes the event in the same transaction as business data; a relay publishes asynchronously.

begin
  update orders
  insert outbox(event)
commit
        → relay reads outbox → publish → mark sent

Schema sketch

CREATE TABLE outbox (
  id           BIGSERIAL PRIMARY KEY,
  topic        TEXT NOT NULL,
  payload      JSONB NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at TIMESTAMPTZ
);

Write path

tx, _ := db.BeginTx(ctx, nil)
_, err = tx.ExecContext(ctx, `UPDATE orders SET status=$1 WHERE id=$2`, "paid", id)
_, err = tx.ExecContext(ctx, `INSERT INTO outbox(topic,payload) VALUES($1,$2)`, "order.paid", payload)
err = tx.Commit()

Relay worker

rows, _ := db.QueryContext(ctx, `
  SELECT id, topic, payload FROM outbox
  WHERE published_at IS NULL
  ORDER BY id LIMIT 100
  FOR UPDATE SKIP LOCKED`)
// for each: publish; UPDATE outbox SET published_at=now() WHERE id=?

SKIP LOCKED allows multiple relays safely.

At-least-once

Consumers still need idempotency (chapter 155)—outbox gives reliable publish attempt, not exactly-once end-to-end.

Rules

Do Don’t
Same TX as state change Publish then commit (or reverse) without outbox
Idempotent consumers Assume no duplicates
Metric lag of unpublished rows Silent outbox growth

Try next

  1. Simulate crash after commit before publish; relay catches up.
  2. Two relays with SKIP LOCKED.
  3. Dashboard: count unpublished > 5m.