What Is the Outbox Pattern?

The outbox pattern — also called the transactional outbox — is a way to publish an event reliably when you commit a database change. It is most associated with microservices, where a service owns its own database and has no shared transaction with the message broker it needs to notify — though the same problem appears in any system that writes to a database and a broker in the same operation.

Instead of writing to the database and then calling the broker, the service writes the business data and an outgoing event row into an outbox table in the same local transaction. A separate process, the message relay, reads that table and delivers the events. Both writes succeed or neither does, without any distributed transaction.

The Dual-Write Problem

Consider an order service that must do two things when an order is placed: save the order to its database, and publish an OrderCreated event so that inventory, billing and notifications can react. Two systems, two separate calls, no shared transaction between them.

This is the dual-write problem, and it has no correct ordering:

  • Commit first, then publish. If the process dies after the commit but before the publish succeeds, the order exists and no downstream service ever hears about it. Inventory is never reserved.
  • Publish first, then commit. If the transaction then rolls back, subscribers act on an order that does not exist. Stock is reserved for a phantom.

The instinct is to wrap the pair in a try/catch and retry the failed half. That narrows the window without closing it: the failure that matters is not an exception you can catch, but the process being killed between the two operations, with no code left running to compensate. Any fix that lives in application control flow has this hole. The outbox pattern closes it by removing the second write from the hot path entirely.

How the Outbox Pattern Works

The outbox table lives in the same database as the business data. That single fact is what makes the pattern work: a local ACID transaction already guarantees that two rows in the same database commit together, so no coordinator is required.

A typical outbox table records what happened, not how it will be delivered:

ColumnPurpose
idUnique event identifier, later used by consumers to detect duplicates
aggregate_type, aggregate_idWhat the event is about — often used as the partition or routing key
event_typeThe event name, such as OrderCreated
payloadThe serialized event body
created_at, processed_atOrdering and delivery bookkeeping

The write side then becomes a single transaction with no network call in it:

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);

    // Same database, same transaction. No broker call here —
    // a network failure at this point cannot split the two writes.
    outboxRepository.save(new OutboxEvent(
        UUID.randomUUID(),          // event id, used later for deduplication
        "Order", order.getId(),     // aggregate type and id
        "OrderCreated",
        serialize(order)
    ));
}

Publishing is now somebody else's job. The message relay reads unprocessed rows, sends them to the broker, and marks them delivered — which is what lets the rest of the system stay event-driven without the writing service ever touching the broker.

The business row and the outbox row commit together, in one local transaction Application database one local transaction (ACID) orders business data outbox pending events Order service @Transactional Message relay polling or CDC Message broker Valkey / Redis / Kafka Consumer services deduplicate by event ID 1 2 3 4 Delivery is at-least-once: a relay that crashes after publishing will send the event again.

Publishing the Events: Polling vs CDC

The pattern says nothing about how the relay works. There are two established approaches, and the choice is mostly about how much infrastructure you are willing to run.

Polling Publisher

The relay queries the outbox on an interval — select unprocessed rows in creation order, publish each one, mark it processed or delete it. It works against any database, needs no additional components, and is easy to reason about and debug.

The costs are real but bounded. Every poll is load on the database whether or not there is work to do. End-to-end latency has a floor set by the polling interval. And the relay must not run twice concurrently, or two instances will publish the same rows — which is why the poller needs either a distributed lock or a locking read such as SELECT ... FOR UPDATE SKIP LOCKED.

Log-Based Change Data Capture

The alternative is to tail the database's own transaction log. A CDC tool such as Debezium reads the write-ahead log, notices inserts into the outbox table, and streams them onward — commonly to Kafka, though the destination is a configuration choice.

CDC removes the polling load, cuts latency to near real time, and preserves commit order for free, since the log is the commit order. In exchange you operate CDC infrastructure and couple the pipeline to your database's replication mechanism. Polling is usually the right first answer; CDC earns its keep once the polling interval has become a latency problem.

CDC also raises a fair question: if you are already tailing the log, why keep an outbox table at all? Capturing changes to the business tables directly does work, but it publishes rows rather than events — subscribers receive a database schema, not a domain vocabulary, and any event that does not correspond to a row change has nowhere to live. The outbox table exists so the service decides what it is announcing.

At-Least-Once Delivery and the Inbox Pattern

The outbox pattern guarantees that an event will be published. It does not guarantee it will be published exactly once. If the relay publishes a message and then crashes before it can mark the row processed, it will find the same row on restart and send it again. Narrowing that window is possible; eliminating it is not, because marking the row and publishing the message are themselves a dual write.

So delivery is at-least-once, and correctness moves to the consumer, which must be idempotent. The usual approach is the mirror image of the outbox: each consumer keeps an inbox of processed event IDs and discards anything it has already seen. Together these are sometimes called the inbox and outbox pattern. The outbox row's id is what makes it work, which is why it should be generated once at write time and carried through the broker unchanged — never regenerated by the relay.

Outbox vs Saga vs Two-Phase Commit

These three are routinely presented as competing options. Only one of them actually competes with the outbox.

Outbox patternSaga patternTwo-phase commit
Problem solvedPublishing an event reliably alongside a commitKeeping a multi-step business process consistentAtomic commit across several resources
GuaranteeEventual, at-least-once deliveryEventual consistency with compensationImmediate atomicity
BlockingNoNoYes — locks held across the prepare phase
CoordinatorNoneOptional orchestratorRequired transaction manager

A saga is not an alternative to an outbox — it is a consumer of one. Each saga step commits locally and then has to tell the next step to begin, which is exactly the dual write the outbox exists to solve. In practice a well-built choreographed saga publishes every one of its step events through an outbox.

The genuine alternative is two-phase commit and XA, which gives strict atomicity across the database and the broker at the cost of synchronous, blocking coordination. The outbox trades that immediacy for availability and throughput: nothing blocks, nothing holds a lock across systems, and the event arrives shortly after the commit rather than during it.

Trade-offs and Gotchas

  • Consistency is eventual. There is a real gap between the commit and the event landing, and any read model built from those events is briefly stale. The write itself stays ACID; it is the consistency between services that becomes BASE.
  • Ordering is not free. Global ordering across the outbox disappears once the relay parallelizes. Per-entity ordering — usually all that is needed — comes from keying the broker partition on aggregate_id.
  • The table grows. Processed rows accumulate on the write path of your primary database. Delete on publish or archive on a schedule; either way it is a job someone has to own.
  • The relay can fail silently. If it stops, events stop while writes keep succeeding, and nothing in the request path notices. Outbox lag is the metric to alert on.
  • The source store must support local transactions. This rules out most NoSQL databases, and is why the outbox table cannot simply be moved into a cache or a broker.
  • Every write carries an extra insert. Small, but on the hot path.

The Outbox Pattern with Valkey and Redis

It is worth being precise about where Valkey and Redis fit, because the answer is not "as the outbox table". The pattern depends on the outbox row committing atomically with the business data, and no local transaction spans a relational database and a separate in-memory store. Valkey and Redis transactions are scoped to a single server, so a two-system write would reintroduce exactly the dual-write problem the pattern removes. If you need strict atomicity across Valkey or Redis and a database, that is the XA route through Redisson's RXAResource, not an outbox.

Where Redisson does the work is on the delivery side — the half the pattern deliberately leaves to your infrastructure.

As the destination. Redisson PRO's Reliable Queue gives the relay a delivery channel with acknowledgments, negative acknowledgments, visibility timeouts, delivery limits and dead-letter destinations, so an event that fails downstream is retried or quarantined rather than dropped. Reliable PubSub covers the fan-out case, where several services each need their own copy of the event, with durable subscriptions, configurable retention and replay by position, ID or timestamp. Redis Streams, through RStream, is the lighter option where replay matters more than acknowledgment semantics.

As the inbox. Because outbox delivery is at-least-once, consumers need deduplication, and Reliable Queue does it natively rather than leaving it to application code. Passing the outbox row's event ID straight into deduplicationById collapses a redelivered event automatically:

RReliableQueue<OrderEvent> queue = redisson.getReliableQueue("order-events");

// The relay publishes using the outbox row's own id. If the relay
// crashes after this call and republishes on restart, the duplicate
// is discarded by the queue rather than by the consumer.
queue.add(QueueAddArgs.messages(
    MessageArgs.payload(event)
        .deduplicationById(outboxRow.getId().toString(), Duration.ofHours(1))
        .deliveryLimit(10)
));

As the relay's coordination layer. The relay must never publish the same row twice at the same time — which means either one active instance, or per-row claiming. Redisson's RLock, or RFencedLock where a stalled instance waking up late would be dangerous, handles the leader election for the first approach, and RScheduledExecutorService runs the poll itself across the cluster. For consumer-side idempotency keys held outside the queue, an expiring RMapCacheNative gives the atomic claim and automatic cleanup that approach needs.

For the transaction mechanics themselves, see How to Manage Transactions in Valkey and Redis on Java, and All About Reliable Queue for the full delivery model.

Outbox Pattern: Frequently Asked Questions

What Is the Difference Between the Outbox Pattern and the Transactional Outbox Pattern?

There is none — they are two names for the same pattern, and both appear in the literature. "Transactional outbox" is the more descriptive of the two, since the defining characteristic is that the outbox row is written inside the same local transaction as the business data. Some sources shorten it further to "outbox table pattern".

Does the Outbox Pattern Guarantee Exactly-Once Delivery?

No. It guarantees that a committed change will eventually produce a published event, which is at-least-once delivery. A relay that publishes a message and then fails before marking the row processed will publish it again on restart. Exactly-once processing is achieved by making consumers idempotent or by deduplicating on the event ID, which is the inbox half of the pattern.

Do I Need Kafka to Use the Outbox Pattern?

No. Kafka is common because log-based change data capture with Debezium publishes to it naturally, but the pattern is broker-agnostic. A polling relay can publish to any destination, including Valkey or Redis through Reliable Queue, Reliable PubSub or Streams. The outbox concerns how the event is recorded, not where it is sent.

When Should You Use the Outbox Pattern Instead of Two-Phase Commit?

Use the outbox when a short delay between the commit and the event is acceptable, which covers most event-driven systems. Use two-phase commit when a wrong intermediate state cannot be tolerated even briefly, and you can afford synchronous, blocking coordination. The outbox scales better and stays available under partial failure; 2PC gives immediate atomicity and holds locks to do it.

Can the Outbox Table Live in a Different Database?

No, and this is the most common way the pattern is broken. If the outbox is in another database, writing the business data and the outbox row is itself a dual write, and the guarantee disappears. The outbox must sit in the same database as the data it describes, so that one local transaction covers both.

Similar terms