The order is saved. The event isn't sent. Now the database says the order exists, but the services waiting for the announcement know nothing about it.
That's the awkward failure between two otherwise sensible lines of code: save the order, then publish OrderCreated. Swapping their order doesn't solve it; publishing first can announce an order that never commits.
The transactional outbox closes that gap by saving the announcement beside the order. Delivery happens afterwards, with retries we can track. Let's look at exactly what becomes atomic, and what still needs care.
Two separate writes leave a gap
Suppose a shop's order service saves a new order in its database, then sends an event to a broker. A notification service listens for that event to prepare the confirmation message.
If the process crashes after saving but before publishing, there's no announcement for the notification service. If it publishes before committing, a database failure can leave the notification service acting on a nonexistent order.
These are separate systems. An ordinary transaction in the order database doesn't automatically include the broker. We need either a suitable cross-system transaction mechanism or a design that makes unfinished publication recoverable.
Put the announcement beside the order
With an outbox, the order service writes two records in one local database transaction: the order and an event describing its creation.
For illustrative tables, the transaction could look like this:
BEGIN;
INSERT INTO orders (id, status) VALUES (318, 'pending');
INSERT INTO outbox (event_id, event_type, payload)
VALUES ('evt-318-1', 'OrderCreated', '{"order_id":318}');
COMMIT;
If the transaction commits, both records are there. If it rolls back, neither is. The event hasn't reached the broker yet; we've saved the intent to publish it.

That is the important promise. We have removed the gap between committing the order and recording that its event needs sending. We haven't made database commit and network delivery one instantaneous operation.
We saved the plan.
Let a relay publish the saved event
A relay finds pending outbox entries and publishes them. It might poll the table, or a change-data-capture process might read committed changes. Either way, publication starts from data that survived the order transaction.
When the broker confirms acceptance under its delivery contract, the relay can record the event as sent. If the broker is unavailable, the event remains pending for another attempt.
The order service can therefore restart without forgetting the announcement. The relay still needs retries, monitoring and a way to recover from failures. An outbox table doesn't send anything by existing.
A relay can send the same event twice
Consider this sequence:
- The relay publishes
evt-318-1. - The broker accepts it.
- The relay crashes before saving its sent marker.
- After restart, the event still looks pending.
The relay may publish it again. Marking it sent before publishing would create a worse gap: a crash could lose the announcement entirely.
The usual solution is to tolerate repeated delivery. Give the event a stable ID, and make consumers handle that ID safely. For a consumer that updates its own database, it can record the processed ID and its local change in the same transaction.
An external action, such as sending email or charging through a provider, needs its own repeat-safe mechanism. A processed-ID table alone doesn't atomically include an external service. The AWS outbox guidance also calls out duplicate handling as part of the pattern.
Order matters when events describe a sequence
One order may produce OrderCreated, then OrderCancelled. If the consumer receives cancellation first and creation second, a naive handler could leave the wrong final state.
Store enough information to preserve or validate the required order, such as an order ID and per-order sequence. Choose broker routing, relay concurrency and consumer behaviour around that requirement. Don't assume a table timestamp guarantees ordered processing across multiple workers.
This doesn't mean every event in the entire shop needs one global sequence. Usually we're interested in the relationship between changes to the same business record.
Which events must stay in order for your consumer's result to remain correct?
Watch the time between commit and delivery
A growing outbox means the application is accepting changes faster than announcements are leaving. Downstream services may be increasingly behind even while order creation looks healthy.
Monitor pending count, oldest pending age and publication failures. Set a policy for retention and cleanup only after you know what recovery, audit and replay require. Deleting unsent entries to shrink a table is deleting unfinished work.
For our shop, that age is also a user-experience signal: how long might a saved order wait before its confirmation process begins? It deserves a threshold somebody can act on.
Commit the intent, then follow the delivery
The outbox turns "we meant to announce this order" into durable data. That's a useful change because a crash no longer erases the need to publish.
The remaining work is explicit: a relay that can recover, consumers that tolerate repeats, ordering where the business requires it, and monitoring for events that aren't moving. Test the crash after publication as carefully as the crash before it. Those two failures explain why the pattern is reliable without pretending delivery is magically exactly once.
Try it yourself
The broker accepted an outbox event, but the relay crashed before marking it sent. Should the restarted relay skip that pending entry to avoid a duplicate?
Share this article with your friends
Know someone who would enjoy this? Send it their way.