~/software-design/transactional-outbox-pattern.md

The Transactional Outbox Pattern: Never Lose an Event Again

4 min readNovember 03, 2026by Gorav Singal#outbox-pattern#system-design#distributed-systems
// TL;DR

Almost every event-driven service wants to do two things on one action: save to its database AND publish an event (or enqueue a job). The trap is the DUAL-WRITE PROBLEM — the DB and the broker are two separate systems with no shared transaction, so a crash in the middle leaves them inconsistent: commit the DB then crash before publishing loses the event; publish then fail to commit creates a ghost event for data that doesn't exist. Retries don't save you — they can double-publish. The TRANSACTIONAL OUTBOX fixes it: write the business row AND an 'outbox' row (the event to publish) in the SAME local database transaction, so both commit or neither does. A separate RELAY — a simple poller, or CDC tailing the outbox table — reads unsent rows and publishes them to the broker, marking each done once acknowledged. Because the relay retries on crash, delivery is AT-LEAST-ONCE, so consumers must be IDEMPOTENT. Outbox guarantees the event is never lost; idempotency handles the duplicate.

The Transactional Outbox Pattern: Never Lose an Event Again

This is one of those patterns that looks like nothing until you get burnt once, and then you never forget it. Picture a very ordinary service method: a user places an order, so you save the order to your database and publish an OrderPlaced event so the warehouse, the email service and everyone else can react. Two lines of code, looks totally innocent. And it is quietly broken.

The dual-write problem

The problem is that you are writing to two different systems — your database and your message broker — and there is no shared transaction across them. So whatever order you do it in, a crash in the middle leaves you inconsistent.

The dual-write problem the outbox solves: a service calls placeOrder, which first does INSERT order to the DB and it commits successfully, then tries to publish the event to the broker but crashes there — the event is lost, so the order exists but nobody was told. Reverse the order and you get the opposite bug: the event is published but the DB rolls back, giving a ghost event for data that does not exist. The reason you cannot just write to both: these are two separate systems with no shared transaction, so a crash in the middle leaves them inconsistent, and retries do not save you because they can double-publish.

Walk through it:

  • DB first, then publish: you commit the order to the database, and then — right before publishing — the process crashes, the broker is down, or the network blips. The order exists, but the event is lost. The warehouse never hears about it. The customer’s order just sits their, silently.
  • Publish first, then DB: you publish the event, and then the database commit fails and rolls back. Now there is an event for an order that does not exist — a “ghost” event. Downstream services act on something that isn’t real.

And “just retry” does not save you — if the publish succeeded but you did not get the acknowledgement, a retry will double-publish. The hard truth is simple: you cannot make two separate systems commit atomically without a shared transaction. So we stop trying to.

The fix: write the event into the same database

Here is the neat trick. Instead of publishing to the broker inside your business transaction, you write the event as a row into an outbox table in the very same database, inside the same transaction as your business data.

The transactional outbox pattern: inside ONE database transaction, write both the orders row (the business data) and an outbox row (the event to publish) — both commit or neither does, atomically, because they live in the same database. Then a separate relay or poller, or CDC, reads unsent outbox rows and publishes them to the broker such as Kafka or a queue, and marks each outbox row as published after the broker acknowledges, which makes delivery at-least-once. Consumers react to the event. Why it works: the event lives in the same database as the business data so one local transaction covers both, no cross-system atomicity is needed, and because the relay retries on crash delivery is at-least-once so consumers must be idempotent.

Now there is only one transaction, in one system, so the usual database guarantee applies: the order row and the outbox row either both commit, or neither does. No more half-states. The event is now durably saved right next to the data it describes.

# ONE local transaction — both rows commit together, or neither does
with db.transaction():
    db.insert("orders", order)
    db.insert("outbox", {
        "id": uuid4(),
        "topic": "orders",
        "payload": OrderPlaced(order.id, order.user_id, order.total),
        "published": False,
    })
# no broker call here at all — that happens later, separately

Then a separate relay does the publishing, out of band:

# a background relay — runs continuously
def relay():
    rows = db.query("SELECT * FROM outbox WHERE published = false ORDER BY created_at")
    for row in rows:
        broker.publish(row.topic, row.payload)     # may be retried
        db.update("outbox", row.id, published=True) # mark done after the ack

The relay can be a simple poller like above, or — much nicer — you point CDC at the outbox table and let it stream new rows to the broker automatically. Either way, the business transaction is clean and the publishing is reliable.

The one rule you must remember: idempotent consumers

There is no free lunch, so here is the catch. If the relay publishes an event and then crashes before it marks the row as published, it will publish that same event again on the next run. This is a deliberate choice — the pattern gives you at-least-once delivery, which means an event is never lost, but it may be delivered more than once.

So the rule is non-negotiable: your consumers must be idempotent. Processing the same event twice must have exactly the same effect as processing it once — usually by tracking the event’s id and skipping one you have already seen. The idempotency pattern article covers exactly how to do this; the two patterns are best freinds and almost always appear together.

Think of it like this: Outbox makes sure the event is never lost. Idempotency makes sure the duplicate never hurts. Together they give you reliable event delivery, which is the whole game in event-driven systems.

When to use it

  • Any time a single action must both change your data and reliably tell the outside world — place order + notify, sign up + send welcome email, payment captured + update ledger.
  • Microservices that communicate through events and cannot afford to silently drop one.
  • As the reliable backbone under a Saga, where each step must dependably trigger the next.

If you only ever write to your database and never publish events, you don’t need it. But the moment you see that “save, then publish” pair in your code, remember — that innocent-looking line is the dual-write trap, and the outbox is how you escape it.

Takeaways

  • The dual-write problem: DB and broker are separate systems with no shared transaction, so a crash between them loses events or creates ghost events. Retries can double-publish.
  • The Transactional Outbox: write the business row and an outbox row in the same DB transaction — atomic, no half-states.
  • A relay (a poller, or CDC on the outbox table) publishes the events out of band and marks them done after the broker acks.
  • Delivery is at-least-once, so consumers must be idempotent — Outbox stops loss, idempotency stops duplicate harm.

Part of the event-driven data patterns series: CQRS, Event Sourcing, Change Data Capture, and its essential partner idempotency. For reliable messaging in general, the Apache Kafka course is a good next stop.

Share

Related Posts

CQRS Pattern, Explained Simply: Split Your Writes From Your Reads

CQRS Pattern, Explained Simply: Split Your Writes From Your Reads

Most of us start our career with one model for everything. The same class, the…

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Here is a question I like to ask in interviews: your bank account shows a…

The Circuit Breaker Pattern: Fail Fast, So One Dead Service Doesn't Kill Them All

The Circuit Breaker Pattern: Fail Fast, So One Dead Service Doesn't Kill Them All

Like the electrical switch in your home that it is named after, a circuit…

Change Data Capture (CDC): Stream Every Database Change Without Touching Your App

Change Data Capture (CDC): Stream Every Database Change Without Touching Your App

Your orders live in Postgres. But you also need them in Elasticsearch for search…

Message Queue vs Stream — RabbitMQ/SQS vs Kafka, and When to Use Which

Message Queue vs Stream — RabbitMQ/SQS vs Kafka, and When to Use Which

“Should we use RabbitMQ or Kafka?” is one of the most common — and most muddled…

Idempotency in Distributed Systems — Why Your Retry Just Charged the Customer Twice

Idempotency in Distributed Systems — Why Your Retry Just Charged the Customer Twice

Here is a bug that has cost real companies real money: a customer taps “Pay ₹50…

Latest Posts

How to Stay Valuable as a Software Engineer in the AI Era: A Practical Playbook

How to Stay Valuable as a Software Engineer in the AI Era: A Practical Playbook

In the previous article I argued that AI is not killing software engineering…

How to Review AI-Generated Code: A Checklist for Catching What the Model Missed

How to Review AI-Generated Code: A Checklist for Catching What the Model Missed

A few months back I approved a PR that was almost entirely AI-generated. It…

How to Debug AI-Generated Code: Understand First, Fix Last

How to Debug AI-Generated Code: Understand First, Fix Last

In the last article I wrote about reviewing AI code before it goes in. This one…

The Future of Software Engineering: What AI Changes, What Stays, and Where the Jobs Go

The Future of Software Engineering: What AI Changes, What Stays, and Where the Jobs Go

Every few weeks somebody sends me a scary headline — “AI will replace all…

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Here is a question I like to ask in interviews: your bank account shows a…

CQRS Pattern, Explained Simply: Split Your Writes From Your Reads

CQRS Pattern, Explained Simply: Split Your Writes From Your Reads

Most of us start our career with one model for everything. The same class, the…