Saved to Postgres, never queued: the transactional outbox for Node Backend engineer Krrish Kumar published a Node.js implementation of the transactional outbox pattern for keeping PostgreSQL writes and BullMQ job enqueues consistent, after testing it against PostgreSQL 18.6 with BullMQ 5.81.5 and 6.5.3. The approach writes the order and an outbox row in one database transaction, then a relay process copies unpublished rows into the queue using FOR UPDATE SKIP LOCKED, with a partial index on unpublished rows and an optional NOTIFY to wake the relay. Kumar notes that BullMQ 6's PostgreSQL-backed queues still expose no connection or transaction type, so queue.add() cannot join the caller's transaction. Here's a bug that never shows up in tests. A request saves an order to Postgres, then calls queue.add 'send-receipt', ... . The deploy restarts the pod between those two lines, or Redis blips for two seconds, and now you have a paid order that never gets a receipt, an invoice or a warehouse pick. Nothing errors. Nothing retries. The two writes were never one unit, and nobody notices until a customer emails. I'm Krrish Kumar, a backend engineer. I wrote this with AI assistance for drafting and editing; the code and every number below come from a test script I ran against PostgreSQL 18.6 and Redis, with BullMQ 5.81.5 and 6.5.3. The fix is an old pattern called the transactional outbox, and this post builds a small version of it in Node, plus the three mistakes I made on the way. There are two orders you can write in, and both are broken: | You do | Crash between the two writes | Result | |---|---|---| | COMMIT , then queue.add | after the commit | Order saved, job never created | | queue.add , then COMMIT | after the add | Job runs for an order that was rolled back | You can't wrap Redis and Postgres in one transaction. Even BullMQ 6, which can now keep its queues in PostgreSQL, doesn't help here: its backend interface "intentionally exposes no connection or transaction type", so queue.add runs on its own connection, outside your transaction. The outbox gets around this by not writing to the queue in the request at all. The request writes the order and a row describing the job in the same database transaction. A separate relay process copies outbox rows into the queue later. If the transaction commits, both rows exist. If it rolls back, neither does. CREATE TABLE outbox id bigserial PRIMARY KEY, topic text NOT NULL, payload jsonb NOT NULL, created at timestamptz NOT NULL DEFAULT now , published at timestamptz ; -- Only unpublished rows are indexed, so the relay's query stays cheap -- even when the table holds millions of published rows. CREATE INDEX outbox unpublished ON outbox id WHERE published at IS NULL; js export async function createOrder pool, { customer, totalPaise } { const client = await pool.connect ; try { await client.query 'BEGIN' ; const { rows: order } = await client.query 'INSERT INTO orders customer, total paise VALUES $1, $2 RETURNING id', customer, totalPaise , ; await client.query INSERT INTO outbox topic, payload VALUES 'order.created', $1 , { orderId: order.id, customer } , ; await client.query 'NOTIFY outbox' ; // delivered only if the transaction commits await client.query 'COMMIT' ; return order.id; } catch err { await client.query 'ROLLBACK' ; throw err; } finally { client.release ; } } The NOTIFY is optional, and it's free: Postgres holds notifications sent inside a transaction until it commits, and drops them on rollback. The relay uses it to wake up immediately instead of waiting for its next poll. js export async function relayBatch pool, queue, { limit = 100 } = {} { const client = await pool.connect ; try { await client.query 'BEGIN' ; const { rows } = await client.query SELECT id, topic, payload FROM outbox WHERE published at IS NULL ORDER BY id LIMIT $1 FOR UPDATE SKIP LOCKED , limit , ; for const row of rows { await queue.add row.topic, row.payload, { jobId: outbox-${row.id} } ; } await client.query 'UPDATE outbox SET published at = now WHERE id = ANY $1 ', rows.map r = r.id , ; await client.query 'COMMIT' ; return rows.length; } catch err { await client.query 'ROLLBACK' .catch = {} ; throw err; } finally { client.release ; } } Three things carry the weight here: FOR UPDATE SKIP LOCKED queue.add calls between them, split 100/150/150/100. COMMIT , the transaction rolls back, the lock goes away, and the next run picks the same row again. That means the relay is jobId: outbox-