All posts
The Transactional Outbox Pattern: Never Lose a Message Again
system designoutbox pattern

The Transactional Outbox Pattern: Never Lose a Message Again

Tien Le H.'s avatarTien Le H.
Table of Contents18 sections

"The order was saved, but the notification never arrived. The payment went through, but the downstream service never knew."

If you've built microservices long enough, you've lived this nightmare.


The Problem Nobody Talks About Enough

Let's imagine you're running a food delivery platform. A customer places an order. Your Order Service does its job perfectly — it validates the cart, reserves inventory, and commits the order to the database. Then, right as it tries to publish an ORDER_CREATED event to the message broker, the application crashes.

The database has the order. The message broker has nothing. The downstream Payment Service is blissfully unaware. The customer's pizza is never made.

This is the core challenge of dual writes in a distributed system: you're trying to keep two separate systems — a database and a message broker — in sync, atomically, with no native transaction spanning both.

The naive solutions all fail:

  • Publish first, then save → What if the save fails? The event fired for something that never happened.

  • Save first, then publish → What if publish fails? The data is committed, but nobody knows.

  • Two-Phase Commit (2PC) → Heavyweight, brittle, and most brokers don't support it.

There's no clean way to make these two operations atomic across different systems — unless you change the approach entirely.


Enter the Transactional Outbox Pattern

The Transactional Outbox Pattern solves this by a clever reframing of the problem:

Stop trying to atomically write to two systems. Instead, write everything to one system — your database — and let a dedicated process handle delivery.

The key insight is this: your database transaction is reliable. It's ACID-compliant. It gives you atomicity, consistency, and durability for free. So instead of publishing events directly to a broker, you write the event into a special table in the same database, in the same transaction as your business data.

That special table is called the Outbox Table.


How It Works: Step by Step

Step 1 — The Outbox Table

Alongside your existing business tables, you create a new table: outbox_messages. Its schema typically includes:

Step 2 — Atomic Write (Business Logic + Outbox)

When your service processes a business action, it does two writes inside a single local database transaction:

BEGIN TRANSACTION

  -- 1. Your normal business logic
  UPDATE payments SET status = 'PAID' WHERE order_id = :id

  -- 2. Write the outgoing event to the outbox
  INSERT INTO outbox_messages (event_type, payload, status)
  VALUES ('ORDER_PAID', { order_id: 123, amount: 49.99 }, 'PENDING')

COMMIT
  • If the transaction commits, both the business data and the outbox record are saved.

  • If the transaction rolls back, neither is saved.

  • If the app crashes after commit, the outbox record survives in the database, ready to be retried.

The atomicity problem is solved entirely within the database, using a mechanism that already works.

Step 3 — The Relay Publisher (Polling Publisher)

Now that events are safely stored in the database, a separate component — the Relay Publisher — takes responsibility for delivering them to the message broker.

The relay publisher runs on a schedule (e.g., every 500ms) and does the following:

LOOP every 500ms:

  messages = SELECT * FROM outbox_messages
             WHERE status = 'PENDING'
             ORDER BY created_at ASC
             LIMIT 100

  FOR each message IN messages:

    TRY:
      broker.publish(message.event_type, message.payload)
      UPDATE outbox_messages SET status = 'PUBLISHED',
             published_at = NOW()
             WHERE id = message.id

    CATCH connection_error:
      -- retry on next polling cycle
      log("Publish failed, will retry")

END LOOP

Key behaviors to note:

  • Ordering is preserved — events are processed in created_at order.

  • Retries are automatic — if the broker is unavailable, the message stays PENDING and will be retried on the next cycle.

  • Idempotency is required — because the publisher might crash after publishing but before marking the message as PUBLISHED, it could publish the same message twice. Consumers must handle duplicate messages gracefully.


Where Should the Relay Publisher Live?

This is a common design question. There are two approaches:

Option

Pros

Cons

Embedded in the Service

The relay publisher runs as a background thread or scheduled job inside the same service (e.g., inside payment-service).

Simple to deploy, no cross-database access needed.

If the service crashes, the publisher is also down. Pending events won't be processed until the service restarts.

Standalone Independent Service

The relay publisher is a separate microservice that has read access to the outbox tables it manages.

Can continue processing events even when the source service is down. Independently scalable.

Requires controlled database access across service boundaries, which some purists find uncomfortable.

In practice, for high-stakes systems or those with multiple publishing channels (like a Notification Service sending via email, SMS, and push simultaneously), a standalone relay publisher per channel is often the better choice, it scales independently and can be added or removed without touching the core service.


A Real-World Case Study: Notification Service

Let's make this concrete with a practical scenario.

Your platform wants to notify customers about their order status — but only those who opted in, and through their preferred channel (email, SMS, or voice call).

The Architecture

Three components come together:

  1. Notification Service — receives notification requests and stores them

  2. Database — holds both the subscriptions table and the notify_outbox table

  3. Channel Publishers — one per delivery channel (email, SMS, voice), each polling its own queue

The Flow

This design is elegant for several reasons:

  • Each publisher is independently deployable and scalable — a surge in SMS volume doesn't affect email delivery.

  • Adding a new channel (e.g., WhatsApp) means adding a new publisher, not modifying the notification service.

  • The outbox guarantees no notification is silently dropped, even during infrastructure failures.


The Transaction Log Tailing Alternative

The polling publisher works well, but it does add extra database load from the constant polling queries. There's a more sophisticated variant worth knowing: Transaction Log Tailing.

Most relational databases maintain a transaction log — a sequential record of every change committed to the database. In MySQL this is the binary log (binlog); in PostgreSQL it's the Write-Ahead Log (WAL).

Instead of polling the outbox_messages table, a log-tailing component (like Debezium — a popular CDC tool) streams changes directly from the database's transaction log. The moment a row is inserted into outbox_messages, the tailing service captures it and publishes the event.

Benefits over polling:

  • Near real-time delivery — no polling delay

  • Zero extra database queries — reads the log, not the table

  • Works even for NoSQL databases that support CDC

Trade-offs:

  • More infrastructure to manage (CDC connector, log configuration)

  • Tighter coupling to database internals

For most teams starting with this pattern, the polling publisher is the right first step. Migrate to log tailing when latency or database load becomes a bottleneck.


Limitations to Know Before You Build

The Transactional Outbox Pattern is powerful, but it's not magic. Go in with clear eyes:

1. Duplicate Message Delivery

Because the relay publisher could crash after publishing but before marking the message as PUBLISHED, at-least-once delivery is the best guarantee — not exactly-once.

Your consumers must be idempotent — processing the same event twice should produce the same result as processing it once. Common techniques:

  • Track processed event_id in the consumer's own database.

  • Make business operations naturally idempotent (e.g., SET status = 'PAID' is safe to run twice; ADD 50 TO balance is not).

2. Not Real-Time

There is an inherent delay between when an event is committed and when it's published to the broker. For most use cases this delay (milliseconds to low seconds) is acceptable. For systems requiring true real-time consistency, this pattern may need to be combined with event streaming architectures.

3. NoSQL Compatibility

The pattern relies on local ACID transactions to guarantee atomicity between the business table and the outbox table. If your database doesn't provide strong transaction guarantees (many NoSQL databases), this pattern either doesn't apply or requires careful adaptation.

4. Polling Overhead

The constant SELECT ... WHERE status = 'PENDING' queries add load to your database. Mitigate this with:

  • Proper indexing on (status, created_at)

  • Reasonable polling intervals (not too aggressive)

  • Eventually migrating to log tailing if it becomes a bottleneck


When Should You Use This Pattern?

Use the Transactional Outbox Pattern when:

  • You need reliable event publishing from a service that also writes to a relational database

  • You're implementing SAGA or event-driven choreography and can't afford lost messages

  • Your message broker doesn't participate in XA/distributed transactions

  • You want a simple, battle-tested guarantee of at-least-once delivery

Skip or reconsider if:

  • Your primary store is a NoSQL database without ACID guarantees

  • You require strictly real-time, sub-millisecond event delivery

  • Your system is small enough that event loss is tolerable and retries are manual


Summary

The Transactional Outbox Pattern boils down to three ideas:

  1. Write events to a table, not a broker — use your database's existing ACID transaction to atomically save business data and the outgoing event together.

  2. Let a relay publisher handle delivery — a separate process polls (or tail-reads) the outbox and publishes to the broker with automatic retry.

  3. Design consumers for idempotency — accept that at-least-once delivery is the contract, and build accordingly.

It's not glamorous. There's no exotic technology involved. But it's one of the most reliable patterns available for solving the dual-write problem in distributed systems — and it's the reason your customers' notifications actually arrive.