Skip to content

Job Processing Modes

attempt
transaction
prepare()read · optional
complete()write · commit

Most jobs don’t need prepare. Call complete directly and you get atomic mode automatically — one transaction for all reads and writes:

'reserve-inventory': {
attemptHandler: async ({ job, complete }) => {
return complete(async ({ finish, txSql }) => {
const [item] = await txSql`SELECT stock FROM items WHERE id = ${job.input.id}`;
if (item.stock < 1) throw new Error("Out of stock");
await txSql`UPDATE items SET stock = stock - 1 WHERE id = ${job.input.id}`;
return finish({ output: { reserved: true } });
});
},
}

This is the default path. If you’re not sure which mode to use, start here.

attempt
transaction
prepare()read
external workno transaction
transaction
complete()write · commit
The worker auto-extends the attempt while the external work runs; that work must be idempotent.

Use staged mode when you need to do work between two transactions — typically external API calls that shouldn’t hold a database transaction open:

'charge-payment': {
attemptHandler: async ({ job, prepare, complete }) => {
// Phase 1: Read state (transaction)
const order = await prepare({ mode: "staged" }, async ({ txSql }) => {
const [row] = await txSql`SELECT * FROM orders WHERE id = ${job.input.id}`;
return row;
});
// Transaction closed, heartbeat active
// Phase 2: External API call (no transaction)
const { paymentId } = await paymentAPI.charge(order.amount);
// Phase 3: Write results (new transaction)
return complete(async ({ finish, txSql }) => {
await txSql`UPDATE orders SET payment_id = ${paymentId} WHERE id = ${order.id}`;
return finish({ output: { paymentId } });
});
},
}
attempt
transaction
prepare()read
transaction
step()one batch
repeat per batch
transaction
complete()write · commit

Within staged mode, step lets you perform multiple independent transactions between prepare and complete — typically for batched work or checkpointed aggregation:

'aggregate-metrics': {
attemptHandler: async ({ job, step, complete }) => {
let totalProcessed = 0;
let cursor;
do {
const batch = await step(async ({ txSql }) => {
const rows = await txSql`
SELECT id, value FROM raw_events
WHERE processed = false
ORDER BY id LIMIT 500
`;
if (rows.length > 0) {
await txSql`
UPDATE raw_events SET processed = true
WHERE id = ANY(${rows.map(r => r.id)})
`;
}
return { count: rows.length, nextCursor: rows.at(-1)?.id };
});
totalProcessed += batch.count;
cursor = batch.count === 500 ? batch.nextCursor : undefined;
} while (cursor);
return complete(async ({ finish }) => finish({ output: { totalProcessed } }));
},
}

Each step call opens a fresh guarded transaction (attempt verified), runs the callback, commits, and flushes hooks. If prepare hasn’t been called, step automatically enters staged mode.

Do you need to call an external API or do long-running
work between reading and writing?
├── No → Just call complete() directly (auto-setup atomic)
└── Yes → Use prepare({ mode: "staged" })
├── Single external call → Read in prepare, do external work, write in complete
└── Batched transactional work → Use step() between prepare and complete

In practice, explicit prepare with a fixed mode is rarely needed. prepare({ mode: "atomic" }) does the same thing as calling complete directly but with extra ceremony. The main reason to use explicit prepare is when the mode is dynamic — determined at runtime based on job input or application state.

When you skip prepare, Queuert infers the mode from how you call complete:

Pattern Mode What happens
return complete(...) (synchronous) Atomic Single transaction wraps everything
await something; return complete(...) Staged Heartbeat runs between async work and complete

This means even without prepare, you can get staged behavior by doing async work before calling complete:

'send-notification': {
attemptHandler: async ({ job, complete }) => {
await emailService.send(job.input.to, job.input.body);
return complete(async ({ finish, txSql }) => {
await txSql`UPDATE notifications SET sent = true WHERE id = ${job.input.id}`;
return finish({ output: { sentAt: new Date().toISOString() } });
});
},
}

The exception is dynamic handlers where the mode is determined at runtime — explicit prepare is the right choice there since auto-setup can’t express conditional logic.

See examples/showcase-processing-modes for a complete working example. See also Job Processing Reliability, Error Handling, Timeouts, and Job Processing reference.