The Transactional Outbox Pattern with PostgreSQL

Photo by Arenamontanus on flickr
It is a technique for publishing events reliably by writing an event row into an outbox table inside the same database transaction as the business change. A separate relay process then reads pending rows from the outbox table and publishes them to a message broker, so the event only exists if the business change actually committed.
The database commit and the message publish are two independent operations against two different systems with no shared transaction. A crash, timeout, or network failure between the two steps can either lose the event entirely or cause it to be sent more than once, since there is no atomic guarantee covering both actions.
Use PostgreSQL's SELECT FOR UPDATE SKIP LOCKED clause when the relay polls the outbox table. Each worker locks a batch of rows, and any other worker polling at the same time automatically skips those locked rows and picks up the next unclaimed batch, so no two workers publish the same event.
Not on its own. The outbox pattern guarantees the event is never lost, giving at-least-once delivery, but a relay crash after publishing and before marking a row as sent can still cause a duplicate. Consumers should treat events as idempotent, for example by tracking processed event ids, to get effectively exactly-once behavior end to end.
Always select and publish rows in ascending order of the outbox table's auto-incrementing primary key rather than by timestamp, and never process events for the same aggregate id concurrently across multiple relay workers. This keeps per-entity ordering intact even though different entities can be published in parallel.

Photo by Arenamontanus on flickr
Key Takeaway
The transactional outbox pattern solves the dual-write problem by writing a domain event into the same database transaction as the business change, then relaying it via a poller using PostgreSQL's FOR UPDATE SKIP LOCKED for safe concurrent processing, guaranteeing the event exists if and only if the business row exists.
Every backend that writes to a database and then publishes an event to a queue eventually hits the same bug: the database commit succeeds, but the message never makes it to the broker, or the reverse happens and a duplicate event goes out. This is the dual-write problem, and it shows up in payment systems, inventory updates, and any workflow where other services need to react to a change.
The transactional outbox pattern solves this by writing the event into the same database transaction as the business change, then relaying it out of band. It trades a small amount of latency and an extra table for a guarantee that is otherwise very hard to get: the event exists if and only if the business row exists. This post walks through the outbox table schema, the write path, a polling relay built on PostgreSQL row locking, ordering guarantees, and where the pattern is overkill.
The naive approach looks reasonable on paper: insert the order row, commit, then publish an order placed event to the message broker. The problem is that these are two separate resources with no shared transaction. Between the commit and the publish call, several things can go wrong.
None of these are exotic failure modes. They happen under normal network flakiness and normal process restarts, and they get worse under load, which is exactly when correctness matters most. The two-phase commit protocol was designed to solve this class of problem for distributed transactions, but it requires the broker to participate as a transactional resource, which most message queues and event brokers do not support.
A caller-side retry loop around the publish call does not fix the dual-write problem, it just changes which failure mode you get. Without an idempotency key or a shared transaction, retries convert lost events into duplicate events.
The core idea is to add a plain table in the same database as your business tables, and treat writing to it as no different from writing to any other row. No new infrastructure, no distributed transaction coordinator, just a table that lives inside a transaction boundary you already control.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
attempts INT NOT NULL DEFAULT 0,
locked_at TIMESTAMPTZ,
locked_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX outbox_pending_idx
ON outbox (available_at)
WHERE status = 'pending';The status and available_at columns exist so the relay can distinguish pending events from ones being retried later, and the locked_at and locked_by columns exist so multiple relay workers can run concurrently without double-processing the same row. The partial index on pending rows keeps the relay's polling query fast even once the table has millions of historical rows.
The write path is the entire trick: wrap the business insert and the outbox insert in one database transaction. If either write fails, the whole transaction rolls back and neither row exists. If the transaction commits, both rows exist. There is no window where one exists without the other.
await db.transaction(async (trx) => {
const order = await trx('orders').insert({
customer_id: customerId,
total_cents: totalCents,
status: 'placed',
}).returning('id');
await trx('outbox').insert({
aggregate_type: 'order',
aggregate_id: order[0].id,
event_type: 'order.placed',
payload: JSON.stringify({ orderId: order[0].id, totalCents }),
});
});Keep the outbox payload as a flat, self-contained JSON document rather than a foreign key back to the business row. The relay and any downstream consumer should be able to process the event without joining back to tables that may have changed or been deleted by the time the event is finally delivered.
The relay is a background worker that repeatedly polls the outbox table for pending rows, publishes each one to the broker, and marks it as sent. Running more than one relay worker for throughput or high availability introduces a new problem: two workers could pick up the same row at the same time and publish it twice. PostgreSQL's row-level locking clauses solve this directly.
SELECT id, event_type, payload
FROM outbox
WHERE status = 'pending' AND available_at <= now()
ORDER BY id
LIMIT 50
FOR UPDATE SKIP LOCKED;SKIP LOCKED tells PostgreSQL to skip any row that another transaction already has locked, rather than blocking until it is free. Each worker locks a batch, publishes it, updates the status, and commits, and any other worker polling at the same moment simply skips those rows and picks up the next unclaimed batch. This turns the outbox table into a safe multi-consumer queue without a distributed lock manager.
| Relay strategy | Typical latency | Operational complexity |
|---|---|---|
| Fixed-interval polling | Seconds, bounded by poll interval | Low, one cron-like worker loop |
| Polling plus LISTEN and NOTIFY wake-up | Sub-second in the common case | Medium, still needs a polling fallback |
| Log-based capture from the write-ahead log | Near real time | High, needs a change-data-capture connector |
Most consumers care about the order in which events for the same entity arrive, even if global ordering across all entities does not matter. Three practices keep ordering intact without adding a separate sequencing service.
SKIP LOCKED gives you safe concurrent processing across rows, but it does not give you an ordering guarantee across workers. If two workers grab two batches at the same moment, rows from a later batch can be published before rows from an earlier batch if the earlier worker is momentarily slower.
An outbox table that is never pruned becomes the slowest table in the database within a few months. Since every row is either pending or already sent, and sent rows are only useful for auditing and debugging, cleanup is straightforward.
Because the outbox table is a normal table, it doubles as a free audit log: you can answer what event fired for a given order, and when, using a plain SQL query, with no separate event store to stand up.
The outbox pattern adds a table, a relay worker, and operational surface area. It earns its keep when a missed or duplicated event has real business cost, such as a payment confirmation or an inventory adjustment. It is overkill in a few common situations.