Skip to content

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

  1. Each file in server/jobs/(Licensees only) is a job, and its file name is the job's kind. At build time modules/jobs-registry.ts(Licensees only) turns the directory into a dispatch table and a QueueJob type.
  2. enqueue(event, { kind, payload }) sends the job to the nuxter-jobs queue through the QUEUE binding.
  3. The same Worker consumes that queue. server/plugins/queue-consumer.ts(Licensees only) finds the handler by kind and runs it. A handler that returns is done. One that throws is retried with a growing delay.
  4. 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.

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

KindWhat it doesEnqueued by
emailResends a rendered mail after a transient failuredeliverEmail in server/email/index.ts(Licensees only)
billing-emailMoves the user's payment-provider customers to a new email00-billing-email.ts on auth:emailChanged
cancel-billingCancels provider subscriptions of a deleted account00-cancel-billing.ts on account:deletionScheduled
org-invitation-emailSends an organization invitation00-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.

TaskDeletesRuns when
activity:pruneActivity entries older than activity.retentionDaysactivity.retentionDays is not false
sessions:pruneRevoked and idle sessionsauth.enabled
account:purgeAccounts whose deletion grace period has endedauth.enabled
notifications:pruneRead notifications older than notifications.retentionDaysnotifications.enabled and retention is not false
invitations:pruneInvitations 30 days past their expiryorgs.enabled
checkouts:pruneExpired open-checkout rowspayments.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

KeyWhereDefaultWhat it does
queue.enabledstarter.config.tstrueTurns the queue on. Off, enqueue returns false.
queues.producers bound as QUEUEwrangler.jsoncnuxter-jobsThe queue enqueue sends to.
max_retries on the jobs consumerwrangler.jsonc5Retries before a job dead-letters.
max_batch_size, max_batch_timeout on the jobs consumerwrangler.jsonc10, 5 secondsMessages per consumer run, and how long Cloudflare waits to fill a batch.
dead_letter_queue on the jobs consumerwrangler.jsoncnuxter-jobs-dlqWhere exhausted jobs go.
Consumer for the dead letter queuewrangler.jsoncbatches of 100Reports and deletes dead jobs.
triggers.cronswrangler.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.
  • enqueue reads QUEUE off the event. A request's event, a listener's event and fabricatedEvent({ cloudflare }) all carry it. An event built without cloudflare makes enqueue return false.
  • Unit tests alias #jobs-registry to a stub with no handlers. Test a job by importing its default export and calling run(payload, event), as test/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.