| name | jobs-queue |
| description | This skill should be used when the user asks about "Payload jobs queue", "Payload background tasks", "Payload workflows", "Payload cron scheduling", "Payload task retries", "Payload runJobs", "Payload autoRun", "queue a job in Payload", or needs to run any background or scheduled work in PayloadCMS. |
PayloadCMS — Jobs Queue
Payload ships a built-in job queue: durable background work backed by your DB. Two primitives:
- Task — a single unit of work with typed input/output, retries, and timeout.
- Workflow — a sequence of inline tasks composed in code. Failures resume from the last successful step.
Jobs are stored in the payload-jobs collection (auto-created). You don't run a separate worker process unless you want to — Payload can execute jobs in-process on a schedule, or you can trigger them with payload.jobs.run() from a cron or webhook.
Defining a Task
import type { TaskConfig } from 'payload'
export const sendOrderConfirmation: TaskConfig<'sendOrderConfirmation'> = {
slug: 'sendOrderConfirmation',
inputSchema: [
{ name: 'orderId', type: 'text', required: true },
{ name: 'email', type: 'email', required: true },
],
outputSchema: [
{ name: 'messageId', type: 'text' },
{ name: 'sentAt', type: 'date' },
],
retries: 3,
handler: async ({ input, req }) => {
const order = await req.payload.findByID({
collection: 'orders',
id: input.orderId,
req,
})
const result = await req.payload.sendEmail({
to: input.email,
subject: `Order ${order.id} confirmed`,
html: `<p>Thanks for your order.</p>`,
})
return {
output: {
messageId: (result as any).messageId,
sentAt: new Date().toISOString(),
},
}
},
}
Register tasks under jobs.tasks in payload.config.ts:
import { sendOrderConfirmation } from './tasks/sendOrderConfirmation'
import { renderPDF } from './tasks/renderPDF'
export default buildConfig({
jobs: {
tasks: [sendOrderConfirmation, renderPDF],
},
})
Queueing a Task
From a hook, endpoint, or anywhere with a Payload instance:
await req.payload.jobs.queue({
task: 'sendOrderConfirmation',
input: {
orderId: order.id,
email: order.customer.email,
},
waitUntil: new Date(Date.now() + 60_000),
})
Returns a Job document — query payload-jobs to see status, retries, output.
Workflows — Multi-Step Jobs
A workflow runs a sequence of inline tasks. State is persisted between steps so retries pick up where they left off:
import type { WorkflowConfig } from 'payload'
export const onboardUser: WorkflowConfig<'onboardUser'> = {
slug: 'onboardUser',
inputSchema: [{ name: 'userId', type: 'text', required: true }],
handler: async ({ job, req, tasks }) => {
const provisioned = await tasks.inline({
task: 'provisionAccount',
input: { userId: job.input.userId },
retries: 2,
})
await tasks.inline({
task: 'sendWelcomeEmail',
input: { userId: job.input.userId, plan: provisioned.output.plan },
})
await tasks.inline({
task: 'notifySlack',
input: { event: , : job.. },
})
},
}
Register under jobs.workflows:
jobs: {
tasks: [provisionAccount, sendWelcomeEmail, notifySlack],
workflows: [onboardUser],
},
Queue it:
await payload.jobs.queue({
workflow: 'onboardUser',
input: { userId: newUser.id },
})
If sendWelcomeEmail fails, retrying the job re-runs from sendWelcomeEmail — provisionAccount is skipped because Payload remembers its output.
Running Queued Jobs
Scheduled publish depends on this. versions.drafts.schedulePublish enqueues publish/unpublish jobs — without a running processor (autoRun, payload jobs:run, or the run endpoint) those events never fire.
In-process autoRun
For low-volume apps, let Payload poll and run jobs automatically:
jobs: {
tasks: [],
autoRun: [
{
cron: '*/5 * * * *',
limit: 10,
queue: 'default',
},
{
cron: '0 * * * *',
queue: 'heavy',
},
],
shouldAutoRun: (payload) => process.env.ENABLE_JOBS === 'true',
processingOrder: { default: 'createdAt', queues: { nightly: '-createdAt' } },
},
Set ENABLE_JOBS=true on exactly one server instance — otherwise multiple replicas race for the same jobs.
External cron / webhook
For higher throughput or dedicated workers, hit Payload's job endpoint from outside:
curl 'https://app.example.com/api/payload-jobs/run?limit=100&queue=nightly' \
-H "Authorization: Bearer $CRON_SECRET"
Gate the endpoint via jobs.access.run in config — check the Authorization header (e.g. Vercel Cron's CRON_SECRET):
jobs: {
access: {
run: ({ req }): boolean => {
const authHeader = req.headers.get('authorization')
return authHeader === `Bearer ${process.env.CRON_SECRET}`
},
},
},
Vercel cron config:
{ "crons": [{ "path": "/api/payload-jobs/run", "schedule": "*/5 * * * *" }] }
Or call from a Node script / Trigger.dev / GitHub Actions workflow:
const { results } = await payload.jobs.run({
queue: 'default',
limit: 50,
})
Bin scripts (dedicated worker)
Run jobs in a separate process — no impact on API response times:
pnpm payload jobs:run --queue default --limit 10 --cron "*/5 * * * *"
pnpm payload jobs:handle-schedules --cron "*/5 * * * *"
This is the recommended runner on dedicated servers. See the cli-recipes skill for flag details.
Cron-only tasks (scheduled work)
Combine cron with autoRun to schedule recurring tasks:
jobs: {
tasks: [
{
slug: 'cleanupExpiredSessions',
retries: 0,
handler: async ({ req }) => {
await req.payload.delete({
collection: 'sessions',
where: { expiresAt: { less_than: new Date().toISOString() } },
req,
})
return { output: {} }
},
},
],
autoRun: [{ cron: '0 3 * * *', queue: 'cron' }],
},
Declarative job schedules (schedule)
Newer Payload versions let a task or workflow declare its own recurring schedule with a schedule array, instead of wiring every cron in the central autoRun. Scheduling only enqueues jobs — you still need a runner (autoRun, a bin script, or an API trigger) to execute them.
import type { TaskConfig } from 'payload'
export const SendDigestEmail: TaskConfig = {
slug: 'SendDigestEmail',
schedule: [
{
cron: '0 0 * * *',
queue: 'nightly',
hooks: {
},
},
],
handler: async () => {
return { output: {} }
},
}
Use schedule when the cadence belongs with the task definition; use autoRun when you want one place to govern which queues run in this process. They compose — schedule decides when to enqueue, autoRun/payload.jobs.run() decides when to drain.
Details worth knowing:
queue is required in each ScheduleConfig entry; the cron expression supports seconds precision (6-field form).
- Due-schedule tracking lives in the auto-created
payload-jobs-stats global.
beforeSchedule can return { shouldSchedule, input } — use the provided countRunnableOrActiveJobsForQueue helper to cap concurrency.
- Scheduling is executed by autoRun ticks (unless the autoRun entry sets
disableScheduling: true), by payload jobs:run --handle-schedules, by GET /api/payload-jobs/handle-schedules, or programmatically via await payload.jobs.handleSchedules().
Retries, Timeouts, Failure Handling
{
slug: 'fetchExternalAPI',
retries: {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
},
timeout: 30_000,
handler: async ({ input, req }) => {
},
onFail: async ({ job, req }) => {
req.payload.logger.error({ msg: 'job permanently failed', jobId: job.id })
await req.payload.create({
collection: 'alerts',
data: { type: 'job.failed', jobId: job.id, reason: job.error?.message },
req,
})
},
}
After attempts exhausted, the job is marked failed and onFail fires.
Querying Jobs
const pending = await payload.find({
collection: 'payload-jobs',
where: { hasError: { not_equals: true }, processing: { not_equals: true }, completedAt: { exists: false } },
})
const failed = await payload.find({
collection: 'payload-jobs',
where: { hasError: { equals: true } },
})
await payload.jobs.run({ where: { id: { equals: jobId } } })
await payload.jobs.runByID({ id: jobId })
await payload.jobs.cancel({ where: { taskSlug: { equals: 'sendOrderConfirmation' } } })
await payload.jobs.cancelByID({ id: jobId })
The admin panel includes a Jobs view by default — go to /admin/collections/payload-jobs.
Patterns
Fire-and-forget from a hook
afterChange: [
({ doc, req, operation }) => {
if (operation === 'create') {
req.payload.jobs.queue({
task: 'sendOrderConfirmation',
input: { orderId: doc.id, email: doc.email },
})
}
return doc
},
],
Use a one-off task — do not await inside the response path if you don't need to.
Idempotent tasks
If the same task may be queued twice (retries, webhooks):
handler: async ({ input, req }) => {
const already = await req.payload.find({
collection: 'emails',
where: { orderId: { equals: input.orderId } },
limit: 1,
})
if (already.docs.length > 0) return { output: { skipped: true } }
}
Long-running tasks
Don't block a single task for hours. Break into a workflow with checkpoint steps so retries pick up partway.
See Also
- The
hooks skill — queueing jobs from afterChange.
- The
adapters skill — transaction semantics affect job persistence.
nextjs-integration skill — invoking payload.jobs.run() from a Vercel cron or scheduled function.