An application saves a customer message and then publishes an event for a worker. If it crashes between those operations, the message exists but the worker never hears about it. Reversing the order creates a different problem: the event can exist when the database change does not.
The transactional outbox addresses this dual-write gap by storing the business change and an outgoing event in the same database transaction. That is a valuable guarantee, but it does not make the entire workflow execute exactly once.
Keep the first guarantee precise
If the transaction commits, both the domain record and the outbox record exist. If it rolls back, neither should exist. A separate relay reads the outbox and publishes events to the broker.
The AWS description of the pattern discusses this transaction boundary and the need to handle duplicate messages. I find it useful to state those guarantees before choosing a relay implementation.
An outbox record should have a stable event ID, tenant context, event type, schema version, and the domain reference needed by consumers. Whether to include a full snapshot or a reference depends on the event’s purpose and consistency requirements.
The relay still has a crash window
Suppose the relay publishes an event successfully and crashes before marking the outbox row as sent. The next relay attempt can publish it again. Marking it sent before publication would instead risk losing it.
I therefore assume the consumer can receive duplicates. Multiple relay workers also need a claiming strategy that prevents uncontrolled contention while allowing recovery when one worker disappears.
Publishing status should include attempts, next eligible retry time, and the last useful error. An ever-growing outbox is a backlog to investigate, not a healthy queue merely because the database is accepting inserts.
Make consumers idempotent at the business boundary
For a consumer whose effect is local database work, I can record the processed event ID and apply the effect in one transaction. A uniqueness constraint prevents two concurrent deliveries from applying the same operation twice.
External effects are harder. If the consumer sends an email or creates a payment action, its database transaction cannot include the remote service. That operation needs a provider idempotency key, a durable operation record, or a reconciliation path for uncertain outcomes.
Broker delivery settings do not remove this application boundary. In a JetStream consumer, acknowledgment and redelivery behavior must match when the application can safely consider work complete. The NATS consumer documentation is the place to verify those mechanics for the deployed configuration.
Decide which ordering the domain needs
Global ordering is often expensive and unnecessary. A conversation may need its own sequence, while unrelated conversations can progress independently. I prefer expressing that domain requirement instead of relying on the incidental order in which workers finish.
Consumers can use an aggregate version or sequence to detect gaps and stale events. The recovery policy then decides whether to wait, reload authoritative state, or request a replay.
Schema versioning matters too. A message that remained in the broker during a deployment may have been produced by older code. Consumers need a compatibility plan for the versions that can still be delivered.
Operate the whole path
I monitor the age of the oldest unpublished event, publication failures, consumer lag, repeated deliveries, and dead-letter volume. Those measurements describe different failure points and should not be collapsed into one queue count.
My failure tests stop the relay after publication, stop the consumer after its side effect, and replay a recorded event. Each test checks whether the system can recover without losing the intended operation or multiplying its effect.
The outbox gives the application a durable promise to publish. Consumers and downstream integrations still have to make that promise useful.
Updated 25 September 2026.
