Building blocks
Background jobs
A background job is work that runs after the response, on a Cloudflare Queue, and gets retried when it fails. Use one when the work calls a slow or flaky service (mail, a payment provider, an AI model) and must not be lost while that service is down. Cron-driven cleanup lives in scheduled tasks at the end of this page.
How it works
- Each file in
server/jobs/(Licensees only) is a job, and its file name is the job'skind. At build timemodules/jobs-registry.ts(Licensees only) turns the directory into a dispatch table and aQueueJobtype. enqueue(event, { kind, payload })sends the job to thenuxter-jobsqueue through theQUEUEbinding.- The same Worker consumes that queue.
server/plugins/queue-consumer.ts(Licensees only) finds the handler bykindand runs it. A handler that returns is done. One that throws is retried with a growing delay. - After the last retry, Cloudflare moves the message to
nuxter-jobs-dlq. The Worker reports each message there as an error and deletes it without running it.
Add a job
Create server/jobs/crm-sync.ts and default-export defineJob:
import { defineJob } from '#server/utils/jobs'
export interface Payload {
userId: number
}
export default defineJob<Payload>({
async run({ userId }, event) {
const [user] = await useDrizzle(event)
.select({ email: tables.users.email })
.from(tables.users)
.where(eq(tables.users.id, userId))
if (!user) return
await $fetch('https://crm.example.com/contacts', {
method: 'POST',
body: { email: user.email },
})
},
})
Nothing else needs editing. The kind is crm-sync, and enqueue takes its
payload type from the run parameter, so a wrong kind or payload shape fails
type checking at the call site. File names must be lowercase kebab-case, or the
build fails. The registry is generated when Nuxt starts, so restart pnpm dev
after you add, rename or delete a job file.
The event passed to run holds the Cloudflare bindings and nothing else.
useDrizzle(event), useRuntimeConfig(event) and
event.context.cloudflare.env work, but there are no headers, cookies or
session. Copy what the job needs from the request into the payload.
Enqueue a job
enqueue lives in server/utils/queue.ts(Licensees only). It
returns true when the queue accepts the job and false when the queue is
off, the event has no QUEUE binding, or the send failed. It never throws, so
the caller decides what false means:
import { enqueue } from '#server/utils/queue'
export default defineEventHandler(async (event) => {
const session = await requireVerifiedSession(event)
const queued = await enqueue(event, {
kind: 'crm-sync',
payload: { userId: session.user.id },
})
if (!queued) {
throw createError({ statusCode: 503, statusMessage: 'Try again later.' })
}
return { queued }
})
The shipped listeners answer false by running the same work inline, as
server/listeners/00-billing-email.ts(Licensees only)
does. A listener's own work is never retried, so enqueue from it when the work
has to survive a failure (Events).
Run jobs locally
pnpm dev has no queue consumer, so enqueue starts the handler in the same
process without awaiting it, logs a [queue] warning naming the kind, and
returns true. Two things differ from production: a throw is logged and
dropped instead of retried, and the payload is passed as-is instead of going
through the queue.
pnpm preview builds the Worker and runs it under wrangler dev with the real
queue, retries and dead letter queue. It needs a real Postgres, not the PGlite
that pnpm dev uses (Database).
Change the retry policy
The consumer waits one minute after the first failure and doubles the wait each
time. With the shipped max_retries: 5, a job gets six attempts over about 31
minutes (waits of 1, 2, 4, 8 and 16 minutes) before it dead-letters.
- The number of retries is
max_retrieson thenuxter-jobsconsumer inwrangler.jsonc(Licensees only). - The spacing is
retryDelaySeconds()inserver/plugins/queue-consumer.ts(Licensees only), capped at 12 hours. Aretry_delayinwrangler.jsonchas no effect, because the consumer passes its own delay on every retry.
Every job shares this policy. A job that shouldn't retry a failure catches it
and returns, as server/jobs/email.ts(Licensees only) does when
every provider refuses a message permanently.
A dead job goes to the error hook, which logs it and forwards it to your
error trackers (Observability) tagged queue and
dead-letter:
[queue] a "crm-sync" job died after 6 attempts: message <id> on "nuxter-jobs-dlq", dead letter delivery 1; acknowledged without running
The report leaves out the payload, and the message is deleted right after, so a dead job can't be replayed. A kind with no handler is retried like any failure, which carries a new job across a deploy while the old Worker is still consuming. Messages for a job file you deleted end up in the dead letter queue.
Turn the queue off
Set queue.enabled: false in starter.config.ts(Licensees only).
enqueue then returns false everywhere and the shipped callers fall back: a
mail every provider refuses returns a 502 instead of being retried later, and
the billing-email sync, invitation mail and subscription cancel run inside
their listeners. The build skips the queues checks and bundles no job
handlers. QueueJob still lists every job, so your enqueue calls still
type-check.
Wrangler still binds whatever the queues block in wrangler.jsonc lists, so
either create the queues anyway or remove that block.
Shipped jobs
| Kind | What it does | Enqueued by |
|---|---|---|
email | Resends a rendered mail after a transient failure | deliverEmail in server/email/index.ts(Licensees only) |
billing-email | Moves the user's payment-provider customers to a new email | 00-billing-email.ts on auth:emailChanged |
cancel-billing | Cancels provider subscriptions of a deleted account | 00-cancel-billing.ts on account:deletionScheduled |
org-invitation-email | Sends an organization invitation | 00-org-invitation-mail.ts on org:invitation:created |
The template owns these four files. Any other file in server/jobs/ is yours,
and pnpm template:update leaves it alone (Template).
Scheduled tasks
Scheduled tasks don't use the queue. They are Nitro tasks in
server/tasks/(Licensees only), run by the cron in wrangler.jsonc
(0 3 * * *, daily at 03:00 UTC). nuxt.config.ts(Licensees only)
registers every enabled task against every cron listed there.
| Task | Deletes | Runs when |
|---|---|---|
activity:prune | Activity entries older than activity.retentionDays | activity.retentionDays is not false |
sessions:prune | Revoked and idle sessions | auth.enabled |
account:purge | Accounts whose deletion grace period has ended | auth.enabled |
notifications:prune | Read notifications older than notifications.retentionDays | notifications.enabled and retention is not false |
invitations:prune | Invitations 30 days past their expiry | orgs.enabled |
checkouts:prune | Expired open-checkout rows | payments.enabled |
Prunes delete 1,000 rows at a time and stop after 20 chunks per run, shared
across all of them. If your tables gain more than 20,000 prunable rows a day,
run the cron more often, for example 0 * * * *.
To add a task, create server/tasks/reports/sync.ts, which Nitro names
reports:sync. A task gets no event, so build one from its context:
import {
fabricatedEvent,
taskCloudflareBinding,
} from '#server/events/fabricated-event'
import { defineReportedTask } from '#server/monitoring/task'
import { enqueue } from '#server/utils/queue'
export default defineReportedTask({
meta: { name: 'reports:sync', description: 'Queue a CRM sync per user' },
async run({ context }) {
const binding = taskCloudflareBinding(context)
if (!binding) throw new Error('reports:sync has no Cloudflare bindings')
const event = fabricatedEvent({ cloudflare: binding })
const users = await useDrizzle(event)
.select({ id: tables.users.id })
.from(tables.users)
for (const { id } of users) {
await enqueue(event, { kind: 'crm-sync', payload: { userId: id } })
}
return { result: { queued: users.length } }
},
})
defineReportedTask sends a throw to your error trackers, so throw when the
bindings are missing rather than returning quietly. Then add 'reports:sync'
to the nitro.scheduledTasks list in nuxt.config.ts. That file belongs to
the template, so expect a small conflict there on updates.
Configuration
| Key | Where | Default | What it does |
|---|---|---|---|
queue.enabled | starter.config.ts | true | Turns the queue on. Off, enqueue returns false. |
queues.producers bound as QUEUE | wrangler.jsonc | nuxter-jobs | The queue enqueue sends to. |
max_retries on the jobs consumer | wrangler.jsonc | 5 | Retries before a job dead-letters. |
max_batch_size, max_batch_timeout on the jobs consumer | wrangler.jsonc | 10, 5 seconds | Messages per consumer run, and how long Cloudflare waits to fill a batch. |
dead_letter_queue on the jobs consumer | wrangler.jsonc | nuxter-jobs-dlq | Where exhausted jobs go. |
| Consumer for the dead letter queue | wrangler.jsonc | batches of 100 | Reports and deletes dead jobs. |
triggers.crons | wrangler.jsonc | ["0 3 * * *"] | When scheduled tasks run. |
There are no environment variables. The build reads the queue names from
wrangler.jsonc, so a rename happens there and nowhere else. With the queue
on, the build fails unless wrangler.jsonc has the QUEUE producer, a
consumer for that queue naming a dead_letter_queue, and a consumer for the
dead letter queue. It also refuses a dead letter queue that is the jobs queue
or names one of its own, and an empty server/jobs/.
Create both queues before the first deploy (Deployment):
pnpm exec wrangler queues create nuxter-jobs
pnpm exec wrangler queues create nuxter-jobs-dlq
Good to know
- A handler can run more than once, and a retry reruns all of it. Make it safe to repeat. For work billed per attempt, keep a status row and check it first.
- Throw only when a retry can help. A failure that will repeat the same way, like bad input or a permanent refusal, should be logged and returned, or it burns six attempts and dead-letters.
- Keep payloads to plain JSON: ids, strings, numbers. A queue message is capped at 128 KB, so store files in R2 (Storage) and pass the key.
enqueuereadsQUEUEoff the event. A request's event, a listener's event andfabricatedEvent({ cloudflare })all carry it. An event built withoutcloudflaremakesenqueuereturnfalse.- Unit tests alias
#jobs-registryto a stub with no handlers. Test a job by importing its default export and callingrun(payload, event), astest/unit/invitation-mail.test.ts(Licensees only) does (Testing).
A link with a lock opens the kit’s private repository, which every licensee can read.