The previous two posts in this series walked through how Queuert keeps job state atomic with your domain writes — on the producer side (startChain inside your transaction) and on the consumer side (atomic and staged complete). Two transactions, both gone. The DB is the single source of truth.
But there's a third kind of side effect that doesn't fit either pattern: work you want to fire after the commit, that shouldn't fail the commit if it fails, and that you'd rather not run at all if the commit rolls back. Sending a NOTIFY for pub/sub. Pinging a metrics endpoint. Logging an audit event to an external sink. Warming a cache.
This problem isn't specific to Queuert or even to background jobs — any application that talks to a database hits it. The pattern that solves it cleanly is a small primitive called transaction hooks: a buffer that collects work during a transaction, flushes it after commit, and discards it on rollback. Queuert ships an implementation (withTransactionHooks) and uses it internally for one of its most-marketed features — sub-second worker wakeup — but the primitive itself stands on its own. You can pull it into any transaction in your codebase, Queuert or not.
A Third Class of Side Effect
Atomic and staged mode both end with a Postgres commit. After that commit lands, there's often follow-up work that's informed by the commit but isn't gated by it in the strict ACID sense.
Pub/sub notifications are the canonical example. When a job is enqueued, you want the worker to wake up — fast. The naive way to do that is to fire NOTIFY new-job immediately after the INSERT. But "immediately after the INSERT" is inside the transaction, and inside the transaction is exactly the wrong place for a notification:
- If the transaction rolls back, listeners get a wakeup for a job that doesn't exist.
- If you batch-insert 100 jobs in one transaction, you fire 100 NOTIFY events — most of them redundant.
- If the NOTIFY adapter is slow, you've slowed your commit.
You want the notification to fire after the commit succeeds, once per batch, without holding up the commit, and never if the transaction rolls back. None of that is what NOTIFY does on its own.
That's the gap transaction hooks fill: a buffer that collects work during a transaction, fires it after commit, and discards it on rollback.
Case Study: Queuert's Own Sub-Second Wakeup
The exact pattern is shipping inside Queuert. Here's the real code from helpers/notify-hooks.ts:
export const bufferNotifyJobScheduled = (
transactionHooks: TransactionHooks,
notifyAdapter: NotifyAdapter,
job: StateJob,
): void => {
const state = transactionHooks.getOrInsert(notifyJobScheduledKey, () => ({
state: new Map<string, number>(),
flush: async (state) => {
await Promise.all(
Array.from(state.entries()).map(async ([typeName, count]) => {
try {
await notifyAdapter.provideWakeHint(typeName, count);
await notifyAdapter.notifyJobScheduled(typeName);
} catch {}
}),
);
},
checkpoint: (state) => {
const snapshot = new Map(state);
return () => {
state.clear();
for (const [k, v] of snapshot) state.set(k, v);
};
},
}));
state.set(job.typeName, (state.get(job.typeName) ?? 0) + 1);
};
Walk through what's happening:
-
getOrInsert(key, factory)lazily registers a hook the first time a job is scheduled inside this transaction. The hook owns aMap<typeName, count>. - Every subsequent
startChain/triggerJob/ continuation inside the same transaction increments the counter for its job type. A hundred inserts of the samesend-emailjob become a single map entry withcount: 100. -
flushruns once, after the transaction commits successfully. It walks the map and fires one wake event per type, passing aprovideWakeHint(typeName, count)so the worker can grab the right batch size in one round-trip. -
checkpointsnapshots the map state so that if aSAVEPOINTrolls back, the hook state rolls back with it. This is what keeps the buffer consistent withSAVEPOINTsemantics, not just top-level commit/rollback. - The
try { } catch {}around the adapter calls is deliberate. A NOTIFY failure can't and shouldn't fail the commit (the commit already happened), and there's no retry — see the next section.
This is the entire reason Queuert's README claims sub-second wakeup without polling. There's no separate signaling system, no outbox table, no cron-based fan-out. The hook buffers wake events during the transaction, dedupes them per type, fires them once on commit, and discards them on rollback. The user-facing API is unchanged — you call startChain; the buffering happens underneath.
The same pattern handles bufferNotifyChainCompletion (waking jobs blocked on a chain) and bufferNotifyJobOwnershipLost (waking the engine when a stale lease is released). Three internal hooks, all the same shape.
What Transaction Hooks Guarantee (and What They Don't)
Once you understand the NOTIFY pattern, the three properties of transaction hooks fall out naturally:
-
Gated on commit. If the transaction rolls back,
discard()runs instead offlush(). The buffered work never fires. This is the property that avoids dual-write. - Can't fail the commit. The hook runs after the commit returns. By definition it can't roll back work that's already durable.
- Not guaranteed to run. If the process dies between commit and flush — pod restart, OOM kill, hard crash — the buffered work is lost. There is no journal, no retry, no second attempt. The buffer lives in process memory.
Property 3 is the one that matters when you reach for hooks yourself. For NOTIFY, it's fine: workers also poll on a slower interval as a fallback, so a missed wakeup just degrades to the polling floor. The hook is an optimization for latency, not a requirement for correctness. Same story for metrics pings (you'll lose a data point), cache warming (the next request will warm it), audit log streaming to a separate sink (you'll lose one event).
The wrong mental model is "hooks are like process.on('commit')." They're not. They're "fire-and-forget, but only if the data actually landed." That's a narrower contract than it looks.
The Footgun: Reaching for Hooks When You Need continueWith
The tempting wrong use is delivery-required work — the kind where losing the side effect is a real bug, not a data-point gap.
Don't do this:
return complete(async ({ sql, transactionHooks }) => {
await sql`UPDATE orders SET payment_id = ${paymentId} WHERE id = ${order.id}`;
// WRONG — losing this on a pod restart means the customer never
// gets their receipt and you have no record that it was queued.
transactionHooks.getOrInsert(emailKey, () => ({
state: [],
flush: async (events) => {
for (const e of events) await sendEmail(e);
},
})).push({ to: order.email, paymentId });
return { paymentId };
});
Do this instead:
return complete(async ({ sql, continueWith }) => {
await sql`UPDATE orders SET payment_id = ${paymentId} WHERE id = ${order.id}`;
// Correct — the send-payment-receipt job is inserted in the same
// transaction as the payment update. It's guaranteed to run, retries
// on failure, and shows up in the dashboard if it gets stuck.
return continueWith({
typeName: "send-payment-receipt",
input: { orderId: order.id, paymentId, to: order.email },
});
});
The continueWith insert is in the same transaction as the payment update — it commits or it doesn't. Once committed, the engine owns the retry, backoff, observability, and dead-letter handling. The cost is one more row in your jobs table; the benefit is at-least-once delivery instead of at-most-once.
Rule of Thumb
-
Must run if the commit succeeded? →
continueWitha next step insidecomplete. Atomic, retried, observable. - Nice to run, fine to drop on process restart? → Transaction hook. Cheap, no overhead, no guarantees.
Both are gated on the commit. Only one is gated on the process surviving the next few milliseconds.
Using the Primitive Standalone
The package exposes withTransactionHooks as a freestanding helper. You don't need a Queuert client, a worker, or even a complete callback to use it — any code that owns a transaction can wrap it in withTransactionHooks and buffer post-commit work the same way:
import { withTransactionHooks } from "queuert";
const metricsKey = Symbol("app.metrics");
await withTransactionHooks(async (transactionHooks) =>
db.transaction(async (tx) => {
await tx.users.create({ email });
await tx.audit.insert({ event: "user-created", email });
transactionHooks.getOrInsert(metricsKey, () => ({
state: [] as Array<{ event: string }>,
flush: async (events) => {
try {
await metrics.batch(events);
} catch {}
},
})).push({ event: "user-created" });
}),
);
If the db.transaction callback throws, the inner SQL rolls back and the metrics ping is discarded — no half-recorded event. If it commits, the buffered work flushes once after the commit returns. Same semantics, no Queuert involved.
The same shape works for any number of named hooks in one transaction — pooled NOTIFY events, batched metrics, cache invalidations, audit-log streaming — each registered under its own Symbol key. You can also call createTransactionHooks() directly when your DB client uses explicit BEGIN/COMMIT/ROLLBACK instead of a callback-style transaction; you just have to remember to call flush() after commit and discard() on error.
Three things to copy from the internal pattern when you write your own hook:
- Use a
Symbolas the key so hooks from different parts of the app don't collide. - Use a collection (Map / Set / Array) for
stateso multiple calls within the same transaction pool into one flush. - Swallow errors in
flushunless you genuinely want them to surface — there's nothing to retry against.
If you need savepoint-safe state (the hook state should roll back when a savepoint rolls back), implement checkpoint too. See the transaction hooks guide for the full API.
Closing
Transaction hooks are a tiny primitive — one function and a hook-definition shape — but they solve a category of problem that comes up everywhere DB work meets external side effects. Queuert uses them under the hood for its own pub/sub layer (no dual-write, no flood of redundant notifications, no commit slowdown), and the same primitive is available standalone for the rest of your post-commit work. The trick is recognizing when "best-effort" is what you actually want, versus when you should reach for the next chain step (or your own retry mechanism) and let something more durable handle delivery.
Top comments (0)