Distributed SystemsMay 15, 20264 min read

Transactional outbox, or: how I stopped losing events

Writing to your database and your message queue in the same breath is a lie — between them, the process can die. Here is the outbox we built: what goes in it and what doesn't, optimistic dispatch with a durable fallback, SKIP LOCKED batch claiming across instances, idempotency-key dedup, backoff, and dead letters.

You update an order and publish order.paid. Two systems, two writes, no shared transaction. Somewhere between the database commit and the broker publish, the process can die — and now your database says the order is paid and your queue never heard about it. The outbox is how you stop lying about that.

Every team eventually writes one. Here is the version we landed on, and the parts that took a few incidents to get right.

What goes in the outbox — and what doesn't

Not everything. An outbox trades latency and a table for durability; you only want that trade where losing the event would be a correctness bug, not an annoyance. The rule we settled on: if an event must be consistent with a database change, it goes in the outbox — written in the same transaction as that change. If it is fire-and-forget (warm a cache, a non-critical nudge), it dispatches directly.

The signal is literally whether the caller hands us a transaction. One entry point takes an EntityManager and enrolls the event in your transaction; the other just ships:

TypeScript
// MUST be consistent with a DB change -> enroll in the caller's transaction
await queue.createEventWithFallback(manager, { eventType: 'order.paid', ... });

// fire-and-forget -> straight to the broker, no outbox row
await queue.dispatchDirectly({ eventType: 'cache.warm', ... });
The rule

If losing it would make the database lie, it goes in the outbox. If losing it is merely annoying, dispatch direct.

Optimistic dispatch, durable fallback

The textbook outbox writes a row and lets a poller ship it "soon." That adds poll latency to every event — a tax you pay even when nothing is wrong. We don't. We write the row inside your transaction, then try to dispatch immediately. If it works — and it almost always does — we mark the row processed inline, so the happy path is exactly as fast as a direct dispatch. If the broker is unreachable, the row simply stays pending, and a background processor takes it from there.

TypeScript
// all inside the caller's DB transaction
await manager.save(outboxEvent);                 // durable: commits with your change
try {
  const job = await dispatchToQueue(event);      // optimistic: ship it now
  await manager.update(OutboxEvent, id, {
    status: 'processed', externalJobId: job.id,
  });
} catch {
  // broker down? leave it 'pending' — the batch processor will retry it
}

Best of both: direct-dispatch speed on a normal day, zero loss on the worst one. The only thing guaranteed to be durable is the intent — the row that committed atomically with your data.

Claiming work without a distributed lock

Several instances run the background processor. How do they avoid grabbing the same rows? Not Redis Redlock, not a consensus library — Postgres FOR UPDATE SKIP LOCKED.

SQL
UPDATE outbox_events
SET status = 'processing', processing_instance = $1
WHERE id IN (
  SELECT id FROM outbox_events
  WHERE status IN ('pending', 'failed')
    AND retry_count < max_retries
    AND (next_retry_at IS NULL OR next_retry_at <= NOW())
  ORDER BY priority DESC, created_at ASC
  LIMIT $2
  FOR UPDATE SKIP LOCKED            -- step over rows another worker already holds
)
RETURNING *;

Each instance atomically claims a disjoint batch. SKIP LOCKED means worker B steps over the rows worker A already locked instead of blocking on them — no contention, one round-trip, and the claim is visible right in the row (processing_instance). Ordered by priority then age, so urgent and oldest drain first.

The database is the coordinator

SKIP LOCKED turns Postgres into the work distributor. No Redlock, no ZooKeeper — just a query that hands each worker a different slice of the queue.

Three heartbeats

The processor runs at three cadences, not one: every 5 minutes to sweep up the handful that failed immediate dispatch, hourly as a steady backstop, and a daily maintenance pass that takes a bigger batch and does the housekeeping. High frequency keeps recovery latency low without a tight poll loop hammering the table.

Dedup, backoff, and the dead letter

A few mechanisms make the at-least-once pipeline safe to lean on:

At-least-once cuts both ways

The idempotency key protects the producer from sending twice. The consumer still has to expect a duplicate and be idempotent itself — the outbox guarantees delivery, not single delivery.

A cousin worth knowing: advisory locks

SKIP LOCKED hands different rows to different workers. Its cousin solves the opposite problem: when you want exactly one instance to run something — an hourly sweep, a single-leader job — and the rest to stand down. Postgres advisory locks do it with one call:

SQL
SELECT pg_try_advisory_lock($1);  -- true for exactly one caller, false for the rest

The instance that gets true runs the job; everyone else skips. The lock is session-scoped, so if the holder crashes it is released automatically and another instance picks up on the next tick — failover with no extra moving parts. Mechanically unrelated to the outbox, but the same instinct runs through both: let the database you already operate be the coordinator, instead of bolting on a second system to keep honest.

The outbox doesn't make publishing reliable — brokers still flake. It makes the record of intent reliable, committed in the same breath as your data, and then never stops trying until intent becomes fact. Slower on the worst day. Correct on every day.

← All field notes
Transactional outbox, or: how I stopped losing events — Anass Houari