sql javascript 110 lines · 3 tabs

Transactional outbox in Node (DB write + event)

Shared by codesnips Jan 2026
3 tabs
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,
    dedupe_key     TEXT        NOT NULL,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at   TIMESTAMPTZ,
    CONSTRAINT outbox_dedupe_key_uniq UNIQUE (dedupe_key)
);

-- Only unpublished rows are indexed, keeping the relay poll fast.
CREATE INDEX outbox_unpublished_idx
    ON outbox (created_at)
    WHERE published_at IS NULL;
3 files · sql, javascript Explain with highlit

The transactional outbox pattern solves a subtle but common distributed-systems problem: a service needs to both change its own database and tell the outside world about that change, but there is no shared transaction spanning the database and the message broker. Writing the row and then publishing to Kafka in two steps means a crash between them either drops the event or, if reordered, publishes an event for a change that never committed. The fix is to write the event into an outbox table inside the same transaction as the business change, then relay those rows asynchronously.

The outbox schema.sql tab defines that table. Each row carries an aggregate_type and aggregate_id so consumers can partition by entity, a JSONB payload, and a dedupe_key with a unique constraint that makes idempotency enforceable at the storage layer. The partial index on published_at IS NULL keeps the relay's polling query cheap even as the table grows, since only unpublished rows are indexed.

In orderService.js, createOrder opens a transaction with BEGIN, inserts the order, and inserts the outbox event using the same client. Because both writes share one transaction, they commit or roll back together — the event can never exist without the order, and vice versa. The dedupe_key combines the aggregate id and event type, and ON CONFLICT DO NOTHING guards against duplicate inserts on retry.

The outboxRelay.js tab is the publisher side. It polls in a loop, claiming a batch with FOR UPDATE SKIP LOCKED so multiple relay instances can run concurrently without processing the same rows. Each claimed event is published to the broker and then marked with published_at. This is deliberately at-least-once: the process may publish and then crash before the update, so a row gets re-sent. That is why consumers must be idempotent, keyed off the event id.

The trade-off is added write amplification and eventual rather than immediate delivery, plus polling latency. In return the system gets a strong guarantee that no committed change silently fails to emit its event, which is usually the property that matters most.


Related snips

Share this code

Here's the card — post it anywhere.

Transactional outbox in Node (DB write + event) — share card
Link copied