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')
COMMITIf 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 LOOPKey behaviors to note:
Ordering is preserved — events are processed in
created_atorder.Retries are automatic — if the broker is unavailable, the message stays
PENDINGand 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 | 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:
Notification Service — receives notification requests and stores them
Database — holds both the
subscriptionstable and thenotify_outboxtableChannel 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_idin the consumer's own database.Make business operations naturally idempotent (e.g.,
SET status = 'PAID'is safe to run twice;ADD 50 TO balanceis 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:
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.
Let a relay publisher handle delivery — a separate process polls (or tail-reads) the outbox and publishes to the broker with automatic retry.
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.
