Loading introduction
Loading stats
Loading about
Loading posts
Loading article
Skip to content
Md Nayeem Hossain
Explorer
home.tsx
transactional-outbox-pattern.md
transactional-outbox-pattern.md
blog.json

// Architecture

The Transactional Outbox Pattern: Reliable Events Without Distributed Transactions

Saving to the database and publishing an event are two writes that can fail independently. The outbox pattern makes them one.

Md Nayeem HossainApr 7, 20253 min read
🏗️

The Transactional Outbox Pattern: Reliable Events Without Distributed Transactions#

Here is a bug I have seen in almost every event-driven codebase: a service saves an order to the database, then publishes an OrderCreated event to a message broker. Most of the time it works. Then one day the process crashes between the two steps, and the order exists but no email is sent, no inventory is reserved and no invoice is created.

Or worse, the publish succeeds and the database commit fails, and downstream services act on an order that never existed.

This is the dual-write problem, and the transactional outbox is the standard way to solve it.

Why "just publish after commit" is not enough#

You cannot atomically commit to two different systems without a distributed transaction protocol like two-phase commit. Most brokers do not support it, and the ones that do make it slow and fragile. Any ordering of "write DB" and "publish event" leaves a crash window where one happens without the other.

The pattern#

Instead of publishing directly, write the event into an outbox table in the same database transaction as the business change. A separate relay process reads the outbox and publishes to the broker.

sql
1CREATE TABLE outbox (2  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),3  aggregate_id uuid        NOT NULL,4  event_type   text        NOT NULL,5  payload      jsonb       NOT NULL,6  created_at   timestamptz NOT NULL DEFAULT now(),7  published_at timestamptz8);9 10CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;

Now the business write and the event are one atomic unit:

ts
1await dataSource.transaction(async (tx) => {2  const order = await tx.getRepository(Order).save({ customerId, items, total });3 4  await tx.getRepository(OutboxEvent).insert({5    aggregateId: order.id,6    eventType: "order.created",7    payload: { orderId: order.id, customerId, total },8  });9});

Either both rows exist or neither does. The crash window is gone.

The relay#

The relay polls for unpublished events, publishes them, and marks them done:

ts
1async function relayBatch() {2  await dataSource.transaction(async (tx) => {3    const events = await tx.query(4      `SELECT * FROM outbox5       WHERE published_at IS NULL6       ORDER BY created_at7       LIMIT 1008       FOR UPDATE SKIP LOCKED`,9    );10 11    for (const event of events) {12      await broker.publish(event.event_type, event.payload, { messageId: event.id });13    }14 15    await tx.query(`UPDATE outbox SET published_at = now() WHERE id = ANY($1)`, [events.map((e) => e.id)]);16  });17}

FOR UPDATE SKIP LOCKED lets you run several relay instances without them grabbing the same rows — a simple and effective way to scale horizontally.

At-least-once, not exactly-once#

If the relay publishes an event and crashes before marking it published, the event goes out again on the next run. The outbox guarantees at-least-once delivery. That is the honest guarantee most real systems offer.

So consumers must be idempotent. The simplest approach is an inbox table keyed by the message ID:

ts
1async function handleOrderCreated(message: Message) {2  await dataSource.transaction(async (tx) => {3    const fresh = await tx.query(4      `INSERT INTO processed_messages (message_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING message_id`,5      [message.id],6    );7    if (fresh.length === 0) return; // already handled8 9    await reserveInventory(tx, message.payload);10  });11}

The outbox on the producer side plus an inbox on the consumer side gives you effectively-once processing, which is what the business actually cares about.

Polling vs change data capture#

Polling is simple and good enough for most teams. If you need lower latency or very high throughput, change data capture tools such as Debezium read the database's write-ahead log and stream outbox inserts to Kafka without any polling query. It is more infrastructure, so I only reach for it when polling becomes a measurable bottleneck.

Operational details that matter#

  • Clean up. Delete or archive published rows after a few days, or the table and its index will grow forever.
  • Monitor lag. Alert on the age of the oldest unpublished event. A growing number means the relay is down or the broker is unhealthy.
  • Keep payloads self-contained. Include what consumers need, so they do not have to call back into your service and couple to its availability.
  • Version event schemas. Add an eventVersion field from day one. You will need it.

Conclusion#

The outbox pattern trades a small amount of latency for correctness: events can be delayed, but never lost and never invented. For any system where a missing or phantom event costs money or trust, that trade is always worth it.

#Event-Driven#Microservices#Reliability#PostgreSQL
OlderReact Server Components in Production: Lessons LearnedNewer Securing Node.js APIs: An OWASP-Driven Checklist

Outline

  • The Transactional Outbox Pattern: Reliable Events Without Distributed Transactions
  • Why "just publish after commit" is not enough
  • The pattern
  • The relay
  • At-least-once, not exactly-once
  • Polling vs change data capture
  • Operational details that matter
  • Conclusion