Skip to content

Transactional Outbox Pattern

CoreConceptJuly 21, 20266 min read

A signup service inserts a new User row, then calls the broker to publish UserRegistered so the welcome-email and loyalty services can react. The database commit and the broker publish are two separate network calls to two separate systems — nothing makes them succeed or fail together. Commit the row, then crash before the publish call, and the user exists with no welcome email, no loyalty account, and no record that anything went wrong.

The transactional outbox pattern fixes this by writing the event as a row in the same local database transaction as the business change, then using a separate relay to deliver it to the broker. This guide follows one signup through the dual-write failure, the outbox table and relay that close it, and a relay crash that tests whether your consumers are actually idempotent. For the broker choice behind the relay, see Kafka vs RabbitMQ vs SQS; for the wider event-driven picture, see Event-Driven Architecture.

Transactional outbox: the event commits with the row, a relay delivers it
Transactional outbox: the event commits with the row, a relay delivers it

The Dual-Write Problem

A signup handler does two things: commit a User row to Postgres, and publish UserRegistered to a broker. Each is reliable on its own — Postgres commits durably, the broker acknowledges durably — but nothing ties them into one atomic operation. Four orderings are possible: commit then publish (the happy path), publish then commit, commit succeeds and publish fails, or publish succeeds and commit fails. Only the first is safe if you publish after commit; the other three all produce an event with no matching row, or a row with no event.

The common instinct — "publish inside the same code path right after commit, wrapped in a try/catch" — does not remove the gap. The process can still crash, the network can still drop the publish call, or the broker can be unreachable, all after the commit already succeeded. The row exists; the event is gone. Nothing will ever re-send it, because nothing recorded that it needed to be sent.

Dual write: two separate commits, no atomicity between them
Dual write: two separate commits, no atomicity between them

Quick reference

  • Two systems, two commits — no cross-system transaction ties them together.
  • The failure is silent: the row exists, nothing indicates a missing event.
  • Publishing before the database commit is worse — you can announce a user who never actually saved.
  • Retrying the publish call on error still leaves a window: success can happen right as the process crashes, before the retry logic runs.
  • This is the same class of problem 2PC solves with locks — see Saga vs Two-Phase Commit for why a broker publish can't just join that lock.

Remember this

A database commit and a broker publish are two separate durability guarantees — nothing links them, so a crash between them loses the event with no trace that it ever should have existed.

Outbox Table and Relay

The fix moves the event into the same transaction as the business write. The signup handler inserts the User row and an outbox_events row — { id, type: "UserRegistered", payload, created_at } — in one local database transaction. Both commit together or neither does; there is no window where one exists without the other, because they were never two separate commits to begin with.

A separate relay process is responsible for actually reaching the broker. It either polls the outbox table for unpublished rows (WHERE published_at IS NULL ORDER BY created_at) and publishes each one, or it tails the database's write-ahead log directly (Debezium reading Postgres logical replication, for example) and reacts to outbox inserts as they're written — no polling delay, but a CDC connector to operate. Either way, the relay marks a row published only after the broker confirms receipt.

Relay: poll unpublished rows, publish, mark sent
Relay: poll unpublished rows, publish, mark sent

Quick reference

  • The outbox row and the business row commit in the same local transaction — the database's own atomicity guarantee does the work.
  • Polling relay: simple to run, adds a polling-interval delay before delivery.
  • CDC/log-tailing relay (e.g. Debezium): near-zero delay, but a new operational component reading the database's replication log.
  • Use the event's own id as the broker message key so downstream deduplication has something stable to key on.
  • A relay is a single-purpose service: read outbox, publish, mark sent — resist adding business logic to it.
Same transaction — row and event commit together
1// Illustrative — single local transaction, two tables2async function registerUser(input: SignupInput) {3  return db.transaction(async (tx) => {4    const user = await tx.users.insert(input);5    await tx.outboxEvents.insert({6      id: crypto.randomUUID(),7      type: "UserRegistered",8      payload: { userId: user.id, email: user.email },9      createdAt: new Date(),10      publishedAt: null,11    });12    return user;13  });14  // Both rows commit together, or the whole transaction rolls back — never one without the other.15}
Polling relay — publish then mark sent
1async function relayOutboxOnce() {2  const rows = await db.outboxEvents3    .where({ publishedAt: null })4    .orderBy("createdAt", "asc")5    .limit(100);6 7  for (const row of rows) {8    await broker.publish(row.type, row.payload, { key: row.id });9    await db.outboxEvents.update(row.id, { publishedAt: new Date() });10  }11}12// Run on an interval, or replace the polling loop with CDC log-tailing.

Remember this

Writing the event as a row in the same transaction as the business change turns 'commit the database, then publish' into one atomic local commit — the relay's job is only to deliver what already safely exists.

At-Least-Once Delivery, Not Exactly-Once

The outbox pattern removes the dual-write gap, but it does not make delivery exactly-once. The relay can publish a row successfully, then crash before it runs the UPDATE ... SET published_at = now() that marks the row sent. On restart, the relay sees an unpublished row and publishes it again — the broker now has two UserRegistered messages for the same signup.

This is a deliberate trade: the outbox guarantees the event is never lost, at the cost of sometimes delivering it more than once. Every consumer of an outbox-relayed event must be idempotent — checking a processed-event table keyed by the event's id before acting, so a redelivered UserRegistered creates one welcome email and one loyalty account, not two.

Publish succeeds, mark-sent crashes — the row is republished on restart
Publish succeeds, mark-sent crashes — the row is republished on restart

Quick reference

  • Relay crash between publish and mark-sent is the specific window that produces a duplicate.
  • At-least-once plus idempotent consumers is the standard combination — not a workaround, the actual contract.
  • An idempotent consumer records processed event ids in the same transaction as its own side effect, so a crash mid-processing can't half-apply an event.
  • Do not make the relay itself try to guarantee exactly-once — that pushes the same dual-write problem one level deeper into the relay's own commit-then-mark-sent step.
  • Order is not guaranteed across different outbox rows unless your broker and partitioning key preserve it — do not assume UserRegistered always arrives before a later UserEmailChanged for the same user unless you've designed for that.

Remember this

The outbox pattern trades exactly-once for never-lost — a relay crash between publish and mark-sent produces a duplicate, so every consumer needs its own idempotency check, not the relay.

Recover One Relay Crash End to End

Trigger. The relay publishes UserRegistered for user-771 to the broker, which acknowledges receipt. Before the relay's UPDATE outbox_events SET published_at = now() commits, the relay process is killed. Symptom. On restart, the relay's next poll finds user-771's outbox row still has published_at = NULL, publishes it again, and the broker now holds two messages for the same signup.

Root mechanism. Publish-then-mark-sent is itself two steps with no atomicity between them — the exact shape of problem the outbox pattern solved for the original business write, recurring one layer down. Recovery: the welcome-email consumer's idempotency check finds user-771's event id already in its processed-events table from the first delivery, and skips sending a second email; the loyalty-account consumer does the same. Verification: query the welcome-email service for user-771 and confirm exactly one sent email, despite two broker messages. Prevention: alert if any outbox row stays unpublished past its expected relay interval — that is the outbox pattern's equivalent of a stuck saga, and it means the relay itself is down, not that an event was lost.

user-771: relay crash between publish and mark-sent produces a duplicate
user-771: relay crash between publish and mark-sent produces a duplicate

Quick reference

  • Trigger: relay crashes between broker publish and marking the outbox row sent.
  • Symptom: the same event id reaches a consumer twice.
  • Recovery: idempotency check at the consumer skips the second delivery — no code change needed at the relay.
  • Verification: query the consumer's own side effect (emails sent, accounts created), not just that the broker delivered a message.
  • Prevention: alert on outbox rows unpublished past the expected interval — that signals a dead relay, not a lost event.
Non-idempotent consumer — the bug the outbox pattern doesn't fix for you
1// Fragile: every delivery sends another email, redelivery or not2async function onUserRegistered(event: UserRegisteredEvent) {3  await emailService.sendWelcome(event.payload.email);4}
Idempotent consumer — safe under redelivery
1async function onUserRegistered(event: UserRegisteredEvent) {2  await db.transaction(async (tx) => {3    const already = await tx.processedEvents.exists(event.id);4    if (already) return; // second delivery of the same event id — no-op5 6    await tx.processedEvents.insert({ id: event.id, processedAt: new Date() });7    await emailService.sendWelcome(event.payload.email);8  });9}10// Verify: replay event.id twice → exactly one email, one processedEvents row.

Remember this

A relay crash between publish and mark-sent produces a duplicate delivery, not a lost one — recovery is the consumer's idempotency check doing exactly what it's there for.

Cleanup and Alternatives

An outbox table grows forever if nothing removes old rows. Delete or archive rows once they are published and past a retention window generous enough to survive a relay outage — deleting immediately after publish removes your only record if the relay's mark-sent step itself needs to be replayed. A periodic job that archives rows older than a few days keeps the table small without deleting evidence you might still need.

The outbox is not the only way to get this right. CDC without an outbox table tails the domain table's own change log directly (no outbox_events table at all) — it removes one table but couples the relay to your domain schema instead of a stable event shape. Two-phase commit could in theory make the database and broker commit together, but almost no message broker participates in a 2PC protocol, and even where XA support exists, it reintroduces the coordinator-lock blocking problem from Saga vs Two-Phase Commit — for a cross-network publish, that lock can be held far longer than a local database transaction.

Outbox table vs CDC-on-domain-tables vs 2PC
Outbox table vs CDC-on-domain-tables vs 2PC

Quick reference

  • Archive or delete published rows after a retention window, not immediately — give yourself replay room if the relay needs to catch up from an outage.
  • CDC-on-domain-tables skips the outbox table but couples the relay to your schema instead of a dedicated event shape.
  • 2PC across a database and a broker is rarely available in practice and reintroduces coordinator blocking for a cross-network call.
  • Index the outbox table on (published_at, created_at) so the relay's poll query stays fast as the table grows.
  • The outbox pattern and the saga pattern combine naturally: each saga step's local transaction can write its own outbox row to trigger the next step.

Remember this

Retain published outbox rows for a window before archiving, and reach for CDC-on-domain-tables or accept 2PC's coordinator lock only if the dedicated outbox table's schema coupling is genuinely the wrong trade for your service.

Key takeaway

The transactional outbox pattern closes the gap between a database commit and a broker publish by making the event a row in the same local transaction as the business write — the database's own atomicity guarantee replaces a cross-system one that doesn't exist. The relay that delivers those rows guarantees at-least-once, not exactly-once, so every consumer needs its own idempotency check.

Practice (25 min): implement registerUser writing a User row and an outbox_events row in one transaction, plus a polling relay that publishes unpublished rows and marks them sent. Kill the relay process after it calls broker.publish but before it runs the mark-sent update, then restart it and confirm the row is republished. Add an idempotency check to your consumer and confirm the duplicate delivery produces exactly one side effect. Pass when you can replay the same event id ten times and see exactly one processed-events row and one email sent.

Share:

Related Articles

RabbitMQ is a broker: producers publish messages; exchanges route them; queues buffer work; consumers process and acknow

Read

Asynchronous messaging decouples services in time — producers send without waiting for consumers. Kafka is a distributed

Read

This guide is for backend engineers who know HTTP and database transactions but need to decide where six microservice pa

Read

Keep learning

Follow a structured path or browse all courses to go deeper.