The dual-write problem creates silent data loss
An order service that saves an order and then publishes order.created has two failure windows. A crash after the commit loses the event; publishing before the commit can create an event for an order that never exists. Retrying can add duplicates. Two independent systems cannot be made atomic with an in-memory promise.
The transactional outbox pattern changes the boundary. In the same database transaction that persists the business change, write a small event record to an outbox_events table. A separate publisher reads committed records and delivers them to the broker. If delivery fails, the record remains available after a restart.
Pair it with the retry-safe contracts in our Node.js idempotency keys guide: one protects client intent, the other protects the event that follows a committed change.
Keep events durable, small, and traceable
An outbox row needs a stable event ID, event type, aggregate ID, payload version, JSON payload, creation time, attempt count, and publishing state. The event ID becomes the consumer's durable deduplication key. Store the business record and the event in one transaction; do not enqueue it in a post-commit callback.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
topic TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
payload JSONB NOT NULL,
payload_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
attempts INTEGER NOT NULL DEFAULT 0
);Event ID
A globally unique value consumers can record with their side effect.
Version
An explicit schema version that permits safe evolution.
Correlation ID
A request or trace ID that connects the event to its origin.
Publish with leases, retries, and acknowledgement
A worker polls in small batches, claims a short lease on rows, publishes them, and marks them published only after broker acknowledgement. Multiple workers need atomic claiming—such as FOR UPDATE SKIP LOCKED or a compare-and-set update—so they do not all publish the same row. Lease expiry lets another worker recover a row held by a crashed process.
Use bounded exponential backoff with jitter when the broker is unavailable. Record error class and next-attempt time; after a defined threshold, move the event to a reviewable failure state and alert. The timeout and dependency-isolation principles in our Node.js circuit breakers guide help protect the worker during an outage.
Make consumers idempotent before production traffic
Every consumer should record the event ID at the same durable boundary as its side effect. An email service can insert the processed event ID with the notification record; a billing projection can use a unique constraint on the event ID. When the broker redelivers, the consumer sees the ID and returns without repeating the action.
- Use a unique constraint or atomic insert, never an in-memory set.
- Make external-provider calls idempotent where possible.
- Define ordering per aggregate only when the domain needs it.
- Version payloads so older consumers can reject incompatible events safely.
For work that starts asynchronously, use the recovery guidance in our Node.js background jobs and queues guide. The outbox is the bridge to a queue, not a replacement for worker reliability.
Operate delivery as a product feature
Measure unpublished-event age, backlog size, publish latency, attempts, permanent failures, duplicate deliveries, and consumer lag. The age of the oldest unpublished event is especially useful: when it rises, customers may be waiting for a downstream action. Log event ID, type, aggregate ID, safe correlation data, and release version.
Keep published records long enough for support and controlled replay, then archive or delete in batches. Test recovery by stopping a publisher after commit, during publishing, and after broker acknowledgement. Each case should converge without losing a business fact. Outbox polling adds database load, so use indexed claims, sensible batches, and the capacity practices in our connection pooling guide.
Transactional outbox production checklist
✓ Business change and event commit in one transaction
✓ Events have stable IDs, versions, and correlation IDs
✓ Publishers claim work atomically and recover leases
✓ Broker acknowledgement precedes marking published
✓ Backoff, alerts, and reviewable failures handle outages
✓ Consumers deduplicate at the durable side-effect boundary
✓ Dashboards track backlog age and delivery latency
✓ Replay and retention are tested before an incident
Build event-driven systems that recover safely
Endurance Softwares helps teams design reliable Node.js and Next.js platforms with durable integrations, observable background work, and production-ready deployment practices.