v3.0 is a robustness-focused major release. The headline change is that the queue is now safe to run in its default mode without any manual bookkeeping: crashed/stuck jobs are reclaimed automatically, poison jobs dead-letter instead of looping forever, and failures are visible by default.
This guide covers every breaking change, the required schema update, the behavioral shifts to be aware of, and the new capabilities worth adopting.
| Change | v2.x | v3.0 | Action required |
|---|---|---|---|
| Default execution mode | transactional: true (exactly-once) |
transactional: false (at-least-once) |
Make workers idempotent, or set transactional: true |
maxAttempts default |
null (unlimited) |
5 (then dead-letter) |
Set maxAttempts: null to keep unlimited |
deleteOn values |
"success" | "failure" | "always" | "never" |
"success" | "always" | "never" |
Replace "failure" usage |
| Default failure logging | silent (debug only) |
console.error |
Attach your own listeners to quiet/route |
| Worker client type (default) | transaction-scoped client | full PrismaClient |
Usually a non-issue; see below |
| Schema | — | adds deadLetteredAt, partial index |
Migrate the database |
Everything else (enqueue, schedule, size, requeueStale, events, retry strategy) is source-compatible.
Add the deadLetteredAt column and (recommended) switch the dequeue index to a partial index that only covers un-finished rows, so it stays small as the table grows.
Prisma schema:
generator client {
provider = "prisma-client"
output = "./client"
previewFeatures = ["partialIndexes"] // required for the partial @@index below (Prisma 7.4+)
}
model QueueJob {
// ...existing fields...
deadLetteredAt DateTime?
@@unique([queue, key, runAt])
// Replaces the v2 full index. Only indexes pending rows.
@@index([queue, processedAt, priority, runAt], where: raw("\"finishedAt\" IS NULL"))
@@map("queue_jobs")
}Then apply it:
prisma generate
prisma db push # or: prisma migrate dev --name prisma_queue_v3If you can't enable the partialIndexes preview feature, keep the column change and create the partial index with raw SQL instead (and drop the old full index):
-- DateTime maps to timestamp(3) (without time zone) by default, matching the other columns
ALTER TABLE queue_jobs ADD COLUMN "deadLetteredAt" timestamp(3);
-- Drop the v2 full index, then create the partial one. Confirm the old name first with `\d queue_jobs`
-- (Prisma's default is queue_jobs_queue_finishedAt_processedAt_priority_runAt_idx).
DROP INDEX IF EXISTS queue_jobs_queue_finishedAt_processedAt_priority_runAt_idx;
CREATE INDEX queue_jobs_queue_processedAt_priority_runAt_idx
ON queue_jobs (queue, "processedAt", priority, "runAt")
WHERE "finishedAt" IS NULL;The partial index is optional — the queue works with any index that covers the dequeue predicate — but it's strongly recommended for tables that retain finished/dead-lettered rows.
v2 defaulted to exactly-once (transactional: true): the worker ran inside the dequeue transaction with a transaction-scoped client. v3 defaults to at-least-once (transactional: false): the job is claimed atomically, then the worker runs outside the transaction with the full PrismaClient.
This is the conventional job-queue default and is the only safe mode for long-running workers or workers that use a separate connection (worker_threads, external services). But it changes delivery semantics, so choose deliberately:
Option A — adopt the new default (recommended). Make your workers idempotent. Under at-least-once, a process crash between completing the work and recording the result (then a lease reclaim) can run a job more than once.
// v3 default — worker receives the full PrismaClient
const queue = createQueue(
{ prisma, name: "email" },
async (job, client) => {
// `client` is the full PrismaClient here (has `$transaction`)
await sendEmailIdempotently(job.payload);
},
);Option B — keep v2 exactly-once. Pass transactional: true explicitly. Best for short, non-idempotent side effects.
const queue = createQueue(
{ prisma, name: "email", transactional: true }, // worker gets a transaction-scoped client
async (job, tx) => {
await tx.somethingTransactional(); // no `$transaction` on a tx-scoped client
},
);Type note:
createQueueoverloads ontransactional. With the new default the worker is typedJobWorkerWithClient(full client); withtransactional: trueit'sJobWorker(transaction-scoped client). Most code compiles unchanged, but if you explicitly annotated the worker'sclientparameter you may need to adjust.
isLocked()returnsfalseduring worker execution in the default mode, since the row lock is released right after claiming.
maxAttempts now defaults to 5. A job that exhausts its attempts is dead-lettered: deadLetteredAt is set, the row is retained for inspection, and a one-shot dead event fires. The cap is also enforced at claim time, so a worker that hard-crashes the runtime (OOM, process.exit) can no longer loop forever.
// Keep v2's unlimited-retry behavior:
const queue = createQueue({ prisma, name: "email", maxAttempts: null }, worker);
// Or handle dead-letters explicitly:
queue.on("dead", (error, job) => alert(`Job ${job.id} dead-lettered`, error));If you previously relied on retryStrategy alone to stop retries, it still works — maxAttempts is just an additional, finite default.
"failure" was removed. Dead-letters are now retained by default so the DLQ is inspectable.
| v2 value | v3 equivalent |
|---|---|
"never" (default) |
"never" — unchanged |
"success" |
"success" — deletes successes, retains dead-letters |
"failure" |
removed — use purge({ olderThanMs, deadLetteredOnly: true }) on a timer |
"always" |
"always" — deletes successes and dead-letters (explicit opt-out of retention) |
// v2: deleteOn: "failure" -> v3: keep dead-letters, prune them deliberately
await queue.purge({ olderThanMs: 30 * 24 * 60 * 60 * 1000, deadLetteredOnly: true });The default error and jobError handlers now console.error instead of only emitting debug output, so failures are visible without DEBUG set. To route or silence them, attach your own listeners (they run alongside the defaults):
queue.on("jobError", (error, job) => myLogger.warn({ jobId: job.id, error }));Worth adopting after the upgrade:
- Automatic stale-job recovery —
staleTimeout(default 6h, non-transactional only). The poll loop auto-reclaims claimed-but-unfinished jobs, so you no longer need to callrequeueStale()on a timer. Set it above your longest job;0/nulldisables. jobTimeout— per-job wall-clock bound (non-transactional). On timeout the job'ssignalis aborted and the attempt fails.stats()—{ pending, scheduled, processing, completed, dead }for monitoring/alerting.enqueueMany(payloads, options?)— single-round-trip bulk insert for high-throughput producers.purge({ olderThanMs, deadLetteredOnly? })— retention helper for finished/dead-lettered rows.deadevent — one-shot signal when a job is permanently dead-lettered.maxRetryDelay— caps the default exponential backoff.- Atomic cron/interval reschedule — in transactional mode the next occurrence is enqueued inside the dequeue transaction, so a crash can't break the recurring chain.
- Add
deadLetteredAtto your schema; apply the migration (prisma db push/migrate). - (Recommended) switch to the partial dequeue index (
partialIndexespreview feature or raw SQL). - Decide execution mode: adopt the at-least-once default with idempotent workers, or set
transactional: true. - Set
maxAttempts: nullif you require unlimited retries; otherwise add adeadhandler. - Replace any
deleteOn: "failure"with retention viapurge(...). - Review default logging — attach
error/jobErrorlisteners if you need custom routing. - (Optional) adopt
staleTimeout,jobTimeout,stats(),enqueueMany(),purge().
v3 only adds a nullable column and changes an index, so a v3 database remains compatible with v2.x code. To roll back the library, revert to your previous transactional/maxAttempts/deleteOn settings; the extra deadLetteredAt column is ignored by v2.
See the README migration section.