Stop Dual-Writing: Reliable Event Delivery with Outbox Pattern

Dual writes between databases and message brokers inevitably cause data drift. Here is how the Transactional Outbox pattern guarantees safe event publication.

Stop Dual-Writing: Reliable Event Delivery with Outbox Pattern
In this essay

Writing to an application database and publishing a message to a message broker within the same business action is one of the most common anti-patterns in backend engineering. This pattern—often called a dual write—inevitably leads to inconsistent system state when network hiccups, process crashes, or broker outages occur. The Transactional Outbox pattern provides an elegant, reliable solution by binding event emission directly to your database's native ACID transaction.

The Anatomy of Dual-Write Failures

In typical web application code, a developer often handles a state change by saving data to a relational database and immediately triggering an event publisher:

async function handleCreateOrder(orderData: CreateOrderDto) {
  const order = await db.orders.create(orderData);
  await messageBroker.publish('OrderCreated', { orderId: order.id });
  return order;
}

This simple snippet harbors two distinct failure modes:

  1. The Database Commits, but the Broker Call Fails: If the message broker is unreachable, returns a timeout, or your application instance crashes immediately after db.orders.create, the database state persists, but downstream consumers never learn that the order was created.
  2. The Broker Call Succeeds, but the Database Transaction Fails: If you invert the order—publishing to the broker before committing the database transaction—a subsequent database error or constraint violation leaves your queue holding a phantom event that points to data that never existed.

Distributed transactions (like Two-Phase Commit / 2PC) could theoretically coordinate both systems, but they are notorious for latency bottlenecks, operational fragility, and lack of support across modern brokers like Apache Kafka.

How the Transactional Outbox Pattern Works

Diagram illustrating the Transactional Outbox pattern with database transaction and message relay

Instead of treating message emission as an external network call during the request cycle, the Transactional Outbox pattern treats it as a local database record. You create a dedicated table—often named outbox_events—within the same relational database.

Whenever a business entity changes, your application writes both the entity and the corresponding event payload inside a single database transaction:

BEGIN;

INSERT INTO orders (id, user_id, amount, status)
VALUES ('ord_123', 'usr_456', 99.00, 'PENDING');

INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload)
VALUES (
  'evt_789',
  'Order',
  'ord_123',
  'OrderCreated',
  '{"userId": "usr_456", "amount": 99.00}'::jsonb
);

COMMIT;

Because both operations belong to one ACID transaction, you achieve atomicity: either both records persist on disk, or neither does. Network failures to external message brokers no longer disrupt the primary user request.

Delivering Events: Polling vs. Change Data Capture (CDC)

Once events sit safely in the outbox table, an independent relay process must read and forward them to your message broker. Two standard strategies handle this relay:

1. Polling Publisher

A background worker polls the outbox_events table periodically for unprocessed rows, publishes them to Kafka or RabbitMQ, and marks them as processed or deletes them. In modern PostgreSQL setups, pairing this with FOR UPDATE SKIP LOCKED allows multiple relay workers to scale horizontally without duplicate polling locks.

While simple to implement without extra infrastructure, polling adds slight latency (depending on poll intervals) and introduces modest read load to your primary database.

2. Transaction Log Tailing (CDC)

Tools like Debezium monitor the database transaction log directly (such as PostgreSQL Write-Ahead Logs / WAL or MySQL binlogs). When the database appends an outbox record, the CDC connector extracts the row and publishes it directly to Kafka with sub-second latency and zero polling overhead.

CDC offers minimal impact on database query throughput, but it requires additional infrastructure management (e.g., Kafka Connect and Debezium clusters).

The At-Least-Once Reality and Idempotency

The Transactional Outbox pattern guarantees that no events are lost, but it provides at-least-once delivery, not exactly-once delivery. If the relay publishes a message to the broker and crashes before marking the outbox row as processed, the subsequent relay worker will re-publish the same event.

Therefore, every downstream consumer must be idempotent. Consumers should store the event_id in a dedicated processed-events log or execute atomic upserts to ensure duplicate events do not create side effects like double payments or redundant emails.

Practical Implementation Guidelines

  • Keep Payloads Self-Contained: Store the full state delta or necessary metadata directly in the outbox payload. Avoid making the consumer call back into the producer's database to look up details.
  • Prune Your Outbox Table: Unchecked outbox tables can grow by millions of rows quickly. Implement a scheduled partition drop or batched cleanup job for processed records.
  • Start with Polling: If your system processes hundreds of events per minute, an in-process background worker or a simple cron script is often sufficient. Graduate to Debezium and Kafka Connect only when throughput or latency requirements demand it.

Dual writing creates silent data corruption that can go undetected for weeks. Moving event generation into the local transaction boundary transforms a fragile distributed problem into a predictable, recoverable database operation.

Sources

  1. vertexaisearch.cloud.google.com
  2. vertexaisearch.cloud.google.com
  3. vertexaisearch.cloud.google.com
  4. vertexaisearch.cloud.google.com
  5. vertexaisearch.cloud.google.com
  6. vertexaisearch.cloud.google.com

AI-assisted · Reviewed by PKN

0

Responses

Loading comments…