Skip to content

Job Processing Modes

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 ({ sql }) => {
const [item] = await sql`SELECT stock FROM items WHERE id = ${job.input.id}`;
if (item.stock < 1) throw new Error("Out of stock");
await sql`UPDATE items SET stock = stock - 1 WHERE id = ${job.input.id}`;
return { reserved: true };
});
},
}

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

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 ({ sql }) => {
const [row] = await sql`SELECT * FROM orders WHERE id = ${job.input.id}`;
return row;
});
// Transaction closed, lease renewal active
// Phase 2: External API call (no transaction)
const { paymentId } = await paymentAPI.charge(order.amount);
// Phase 3: Write results (new transaction)
return complete(async ({ sql }) => {
await sql`UPDATE orders SET payment_id = ${paymentId} WHERE id = ${order.id}`;
return { paymentId };
});
},
}
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" })
Read in prepare, do external work, write in 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:

PatternModeWhat happens
return complete(...) (synchronous)AtomicSingle transaction wraps everything
await something; return complete(...)StagedLease renewal 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 ({ sql }) => {
await sql`UPDATE notifications SET sent = true WHERE id = ${job.input.id}`;
return { 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 Error Handling, Timeouts, and Job Processing reference.