Back to Blog
ArchitectureJuly 4, 20265 min read

Transactional Outbox Pattern: Solving the Dual Write Problem

The transactional outbox pattern ensures data consistency between your database and message broker. Learn how to fix the dual write problem effectively.

distributed systemsdatabasearchitectureoutbox patternmicroservicesconsistencySystem DesignInterview

We’ve all been there: you update a record in your PostgreSQL database and then fire an event to Kafka. Everything works fine until the network blips, the message broker goes down, or the application crashes exactly between those two operations. Suddenly, your database has the new state, but your downstream microservices are completely out of sync.

That’s the "dual write problem." It’s the classic failure mode of distributed systems consistency. You can't wrap a database commit and an API call to a message broker in a single ACID transaction. They are two separate systems, and they don't know how to talk to each other to ensure atomicity.

Understanding the Transactional Outbox Pattern

The transactional outbox pattern solves this by moving the event emission out of the request-response cycle. Instead of sending the message directly to the broker, you insert the event into an outbox table within the same database transaction that updates your domain entity.

Because both the domain record and the outbox record are updated in the same local transaction, you get atomic guarantees. If the database transaction succeeds, the message is effectively "in the queue." If it fails, nothing happens, and you don't end up with partial state updates.

Why We Moved Away from Dual Writes

Early in my career, I tried a "retry-and-reconcile" approach. We would wrap the broker call in a try-catch block and implement a background job to sync the database with the broker. It was a nightmare. We spent about two weeks just fixing edge cases where the message was sent but the database update failed, or the broker acknowledged the message but we failed to record that locally.

We eventually shifted to a dedicated outbox table. Here is the basic flow:

  1. Application Logic: Start a database transaction.
  2. Domain Update: Update your primary table (e.g., orders).
  3. Outbox Insert: Write the event payload to an outbox table.
  4. Commit: Commit the transaction.

By using this approach, we ensure database atomicity while maintaining message broker reliability via a separate process that polls the outbox table.

Implementing the Pattern

You generally have two ways to move data from the outbox table to your broker: polling or log-based capture.

The Polling Publisher

This is the simplest implementation. You run a scheduled task (like a cron job or a persistent worker) that polls the outbox table for new entries.

SQL
-- Simple polling query
SELECT * FROM outbox WHERE processed = false ORDER BY created_at ASC LIMIT 10;

Once the worker successfully publishes the message to the broker, it marks the entry as processed = true. If you're using Laravel, you can master the Transactional Outbox Pattern in Laravel: Ensuring Data Consistency by leveraging built-in dispatchers.

Log-Based Capture

For high-throughput systems, polling the database can add unnecessary load. Instead, you can use Change Data Capture (CDC) tools like Debezium to read the database's Write-Ahead Log (WAL). This approach is far more efficient, as it doesn't query your application tables at all. You can read more about this in my guide on Transactional Outbox Pattern: Using WAL for Reliable Event-Driven Systems.

ApproachComplexityPerformance ImpactLatency
PollingLowHigh (Query load)Moderate
CDC (WAL)HighLowVery Low

Dealing with At-Least-Once Delivery

One thing to keep in mind: the transactional outbox pattern guarantees at-least-once delivery. Because the publisher could crash after sending the message to the broker but before marking the record as processed in the database, you might send the same event twice.

Your downstream consumers must be idempotent. If they receive the same order-created event twice, they should simply ignore the second one or verify the current state before applying changes. We've found that simple database constraints or tracking processed event IDs in the consumer's database solves this effectively.

When to Use This Pattern

If you're building a monolith that doesn't talk to other services, you don't need this. But as soon as you start splitting services and relying on event-driven communication, this pattern becomes your safety net. It’s a core component for Change Data Capture via Transactional Outbox for Distributed Consistency.

I’m still experimenting with how to handle large-scale event schemas in the outbox. As the number of events grows, the outbox table can become a bottleneck if not indexed correctly. Always index your processed and created_at columns, or you'll see your database performance tank as the table grows into the millions of rows.

Frequently Asked Questions

1. Does the outbox pattern hurt database performance? It adds an extra write per transaction, which is usually negligible compared to the cost of network calls to a broker. The real cost comes from table bloat, so ensure you have a mechanism to prune or archive processed records.

2. Can I use this for non-relational databases? It's much harder. The pattern relies on ACID transactions across tables. If you're using NoSQL, you might need to look into native transaction support or sacrifice some consistency for availability.

3. Is there a way to avoid duplicate events? Strictly speaking, no. In distributed systems, achieving exactly-once delivery is extremely difficult. It’s better to focus on making your consumers idempotent so that duplicate processing is harmless.

Next time, I'd like to explore how to implement this using native database triggers rather than application code. It feels cleaner, but it’s harder to debug when things go wrong in production.

Similar Posts