All articles
Distributed Systems
Reliability in Distributed SystemsPart 02

Transactional Outbox: A Database Commit Doesn't Mean the Message Was Delivered

In distributed systems, committing business data and publishing the corresponding event are two operations with different failure points.

6 min readTürkçe oku

Many applications need to update business data and then notify another system.

An order is created, followed by a notification to inventory services. A work order is stored, and other components need to know about it. An event from a remote device is persisted and then forwarded for further processing.

The flow appears straightforward:

  1. Commit the data.
  2. Publish a message.
  3. Complete the operation.

But when those actions involve independent systems, there is a reliability gap between them.

The database succeeds, but the message fails

Suppose a service creates a work order in its database.

The transaction commits successfully. The service then attempts to publish a WorkOrderCreated event to a message broker.

At that moment, the network connection fails or the application crashes.

The work order now exists, but downstream systems may never hear about it.

Reversing the order does not eliminate the problem. If the message is published first, the database transaction may subsequently fail. Other systems could receive an event describing an operation that never committed.

The underlying issue is that two independent systems cannot generally be updated atomically using one local database transaction.

Successfully committing business data does not guarantee that the corresponding message was published.

What does Transactional Outbox change?

The Transactional Outbox pattern records the business change and the intention to publish an event within the same database transaction.

When the service creates a work order, it also inserts an event into an outbox table.

For example:

BEGIN;

INSERT INTO work_orders (id, status)
VALUES ('WO-1042', 'Created');

INSERT INTO outbox_messages
    (id, event_type, aggregate_id, payload, created_at)
VALUES
    (
        gen_random_uuid(),
        'WorkOrderCreated',
        'WO-1042',
        '{"workOrderId":"WO-1042"}'::jsonb,
        NOW()
    );

COMMIT;

Both records commit together or roll back together.

At this point, no message has necessarily reached RabbitMQ, Kafka, or any other messaging system.

The guarantee is more specific:

If the business transaction commits, the corresponding event is also durably recorded for publication.

That distinction is the foundation of the pattern.

Who publishes the message?

A separate process reads pending outbox records and publishes them to the messaging infrastructure.

This may be a background worker, a dedicated service, or a change-data-capture-based mechanism.

A simplified flow looks like this:

Business Operation
       |
       v
Database Transaction
   |            |
   v            v
Business Data   Outbox Event
   |            |
   +-----+------+
         |
       COMMIT
         |
         v
    Outbox Worker
         |
         v
    Message Broker
         |
         v
       Consumer

After the broker confirms publication, the worker can mark the outbox entry as delivered.

However, another failure scenario remains.

The message is published, but the record is not updated

The worker publishes the message.

The broker accepts it and acknowledges the publication.

Immediately afterward, the worker crashes.

The outbox entry has not yet been marked as delivered.

When the worker restarts, it may publish the same message again.

Transactional Outbox closes an important message-loss window, but it does not eliminate duplicate publications.

This is not a flaw in the implementation. It is a behavior that the architecture must account for.

Under appropriate operational assumptions, the publication mechanism provides at-least-once delivery rather than exactly-once delivery.

That makes duplicate-tolerant consumers essential.

Why idempotent consumers matter

When a consumer receives the same event again, it should avoid repeating an unintended business effect.

Events need stable identifiers so that a retry of the same logical event does not appear as an entirely new event.

One approach is to store the processed message identifier in the same transaction that updates the consumer’s business data.

This is often implemented through an Inbox or Processed Messages pattern.

However, if the consumer calls an independent external service or executes a physical command, a local database record alone may not be sufficient.

Transactional Outbox and idempotency are not alternatives. They address different reliability concerns and often need to work together.

The outbox is an operational component

Transactional Outbox involves more than adding a table.

A production design must answer several questions:

  • How large can the backlog grow while the publisher is unavailable?
  • How do multiple workers safely claim pending records?
  • How are failed publications retried?
  • Can a permanently failing event block other events?
  • Does message ordering matter, and within what scope?
  • How are completed records archived or deleted?
  • How can we detect messages that have remained pending too long?

For example, PostgreSQL workers may use techniques such as FOR UPDATE SKIP LOCKED to coordinate concurrent processing. Record ownership, transaction duration, and crash recovery still need deliberate design.

Ordering also deserves attention. Sorting by created_at does not automatically preserve the required event order across concurrent transactions, multiple publishers, and retries.

These are operational decisions, not simply implementation details.

Does every system need an outbox?

No.

For some applications, a delayed or missing notification may have limited consequences. In others, losing an event could leave business workflows permanently inconsistent.

A temporary user-interface notification and an event that triggers the next stage of a financial transaction do not necessarily require the same reliability guarantees.

Transactional Outbox introduces additional complexity: durable event storage, publisher processes, monitoring, retention policies, and recovery mechanisms.

The decision should therefore be driven by the business importance of an event and the consequences of losing it, rather than by a preference for a particular messaging technology.

Conclusion: Design for recoverable publication

Reliability in distributed systems is not only about making the successful path work.

It is about what the system can do when one of its steps fails.

Transactional Outbox does not promise that every message will be processed exactly once.

Instead, it makes publication intent durable alongside the business transaction, providing a stronger foundation for recovery.

Messages may still be delayed. The same message may be published again. Consumers may fail.

But the system retains a durable record of what still needs to be published.

Reliability does not come from the absence of failures. It comes from the ability to recover and continue consistently after them.

Hands-on experiment: Why the outbox alone is not enough

E02 — Duplicate Delivery, part of the Distributed Reliability Lab, demonstrates an important limitation of the Transactional Outbox pattern.

In the experiment, the outbox worker publishes an event to RabbitMQ and receives publisher confirmation. It then crashes before recording that the event was published. After restarting, the worker publishes the same event again.

A separate failure scenario occurs when a consumer commits its business transaction but crashes before acknowledging the message. RabbitMQ subsequently redelivers it.

In both cases, duplicate delivery is expected. What matters is how those duplicates affect business state.

The experiment shows why Transactional Outbox and idempotent consumers are complementary mechanisms rather than interchangeable solutions.

Explore E02 — Duplicate Delivery on GitHub

The problem the outbox does solve is shown in E01 — Lost Event: when a service using a plain dual write crashes between its commit and the publish, the event is lost. After the same crash, the outbox service publishes the pending event when it restarts.

Explore E01 — Lost Event on GitHub

#transactional-outbox#messaging#consistency#idempotency