Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

Jobs

Now that we have covered Tasks and Workflows, we can tie them together with a concept called a Job.

For example, let's say we have a Workflow or Task that describes the logic to sync information from Payload to a third-party system. This is how you'd declare how to sync that info, but it wouldn't do anything on its own. In order to run that task or workflow, you'd create a Job that references the corresponding Task or Workflow.

Jobs are stored in the Payload database in the payload-jobs collection, and you can decide to keep a running list of all jobs, or configure Payload to delete the job when it has been successfully executed.

Queuing a new job

In order to queue a job, you can use the payload.jobs.queue function.

Here's how you'd queue a new Job, which will run a createPostAndUpdate workflow:

1
const createdJob = await payload.jobs.queue({
2
// Pass the name of the workflow
3
workflow: 'createPostAndUpdate',
4
// The input type will be automatically typed
5
// according to the input you've defined for this workflow
6
input: {
7
title: 'my title',
8
},
9
overrideAccess: true,
10
})

In addition to being able to queue new Jobs based on Workflows, you can also queue a job for a single Task:

1
const createdJob = await payload.jobs.queue({
2
task: 'createPost',
3
input: {
4
title: 'my title',
5
},
6
overrideAccess: true,
7
})

Where to Queue Jobs

Jobs can be queued from anywhere in your application. Here are the most common scenarios:

From Collection Hooks

The most common place - queue jobs in response to document changes:

1
{
2
slug: 'posts',
3
hooks: {
4
afterChange: [
5
async ({ req, doc, operation }) => {
6
// Only send notification for published posts
7
if (operation === 'update' && doc.status === 'published') {
8
await req.payload.jobs.queue({
9
task: 'notifySubscribers',
10
input: {
11
postId: doc.id,
12
},
13
overrideAccess: true,
14
})
15
}
16
},
17
],
18
},
19
}

From Field Hooks

Queue jobs based on specific field changes:

1
{
2
name: 'featuredImage',
3
type: 'upload',
4
relationTo: 'media',
5
hooks: {
6
afterChange: [
7
async ({ req, value, previousValue }) => {
8
// Generate image variants when image changes
9
if (value !== previousValue) {
10
await req.payload.jobs.queue({
11
task: 'generateImageVariants',
12
input: {
13
imageId: value,
14
},
15
overrideAccess: true,
16
})
17
}
18
},
19
],
20
},
21
}

From Custom Endpoints

Queue jobs from your API routes:

1
export const POST = async (req: PayloadRequest) => {
2
const job = await req.payload.jobs.queue({
3
workflow: 'generateMonthlyReport',
4
input: {
5
month: new Date().getMonth(),
6
year: new Date().getFullYear(),
7
},
8
overrideAccess: false,
9
req,
10
})
11
12
return Response.json({
13
message: 'Report generation queued',
14
jobId: job.id,
15
})
16
}

From Server Actions

Queue jobs from Next.js server actions:

1
'use server'
2
3
import { headers } from 'next/headers'
4
import { createLocalReq, getPayload, UnauthorizedError } from 'payload'
5
import config from '@payload-config'
6
7
export async function scheduleEmail() {
8
const payload = await getPayload({ config })
9
const { user } = await payload.auth({ headers: await headers() })
10
11
if (!user) {
12
throw new UnauthorizedError()
13
}
14
15
const req = await createLocalReq({ user }, payload)
16
17
await payload.jobs.queue({
18
task: 'sendEmail',
19
input: { userId: user.id },
20
overrideAccess: false,
21
req,
22
})
23
}

Job Options

When queuing a job, you can pass additional options:

1
await payload.jobs.queue({
2
task: 'sendEmail',
3
input: { userId: '123' },
4
overrideAccess: true,
5
6
// Schedule the job to run in the future
7
waitUntil: new Date('2024-12-25T00:00:00Z'),
8
9
// Assign to a specific queue
10
queue: 'high-priority',
11
12
// Add custom metadata for tracking
13
log: [
14
{
15
message: 'Email queued by admin',
16
createdAt: new Date().toISOString(),
17
},
18
],
19
})

Common options

  • waitUntil - Schedule the job to run at a specific date/time in the future
  • queue - Assign the job to a specific queue (defaults to 'default')
  • log - Add custom log entries for debugging or tracking
  • req - Pass the request context for access control
  • overrideAccess - Skip Jobs access control when set to true (defaults to false)

Check Job Status

After queuing a job, you can check its status:

1
const job = await payload.jobs.queue({
2
task: 'processPayment',
3
input: { orderId: '123' },
4
overrideAccess: true,
5
})
6
7
// Later, check the job status
8
const updatedJob = await payload.findByID({
9
collection: 'payload-jobs',
10
overrideAccess: true,
11
id: job.id,
12
})
13
14
console.log(updatedJob.completedAt) // When it finished
15
console.log(updatedJob.hasError) // If it failed
16
console.log(updatedJob.taskStatus) // Details of each task

Job Status Fields

Each job document contains:

1
{
2
id: 'job_123',
3
taskSlug: 'sendEmail', // Or workflowSlug for workflows
4
input: { userId: '123' }, // The input you provided
5
completedAt: '2024-01-15...', // When job completed (null if pending)
6
hasError: false, // True if job failed
7
totalTried: 1, // Number of attempts
8
processingUntil: null, // Future date while a worker owns the job
9
processingToken: null, // Internal token for the current owner
10
taskStatus: { // Status of each task (for workflows)
11
sendEmail: {
12
'1': {
13
complete: true,
14
output: { emailSent: true }
15
}
16
}
17
},
18
log: [ // Execution log
19
{
20
message: 'Job started',
21
createdAt: '...'
22
}
23
]
24
}

Payload increments totalTried after each completed attempt. While the handler runs, Payload automatically renews processingUntil. The claim also gets a processingToken, and worker updates only start while that token still matches and enough lease time remains for the configured safety buffer. If the worker disappears, the lease expires and a later jobs runner call can claim the job again. A crashed worker does not increment totalTried or consume a handler retry.

Access Control

Use Payload's dedicated Jobs APIs and endpoints, such as payload.jobs.*, /run, and /handle-schedules. Calling generic Local API, REST, or GraphQL CRUD directly on payload-jobs is not recommended because it bypasses the job operations and does not run jobs.access.*.

Raw collection create, read, update, and delete access is denied by default. You can adjust these rules through jobsCollectionOverrides. REST, GraphQL, and Local API CRUD run these access rules by default. Set overrideAccess: true only for trusted server-side operations that should bypass them.

The dedicated job operations use jobs.access.queue, run, and cancel. payload.jobs.queue(), run(), runByID(), cancel(), and cancelByID() run these functions by default because their overrideAccess option defaults to false. Set overrideAccess: true to bypass Jobs access control for trusted server-side work. The /run and /handle-schedules endpoints always check jobs.access.run.

Configure access for the dedicated job operations in your Jobs Config:

1
import type { SanitizedConfig } from 'payload'
2
3
const config: SanitizedConfig = {
4
// ...
5
jobs: {
6
access: {
7
// Control who can queue new jobs
8
queue: ({ req }) => {
9
return req.user?.roles?.includes('admin')
10
},
11
// Control who can run jobs
12
run: ({ req }) => {
13
return req.user?.roles?.includes('admin')
14
},
15
// Control who can cancel jobs
16
cancel: ({ req }) => {
17
return req.user?.roles?.includes('admin')
18
},
19
},
20
},
21
}

These functions receive the current req and return a boolean. By default, they allow authenticated users.

Omit overrideAccess or pass overrideAccess: false to run them from the Local API:

1
const req = await createPayloadRequest({ payload, user })
2
3
await payload.jobs.queue({
4
workflow: 'createPost',
5
input: { title: 'My Post' },
6
overrideAccess: false, // Enable access control
7
req, // Pass the request with user context
8
})

Cancelling Jobs

Payload allows you to cancel jobs that are either queued or currently running. When cancelling a running job, the current task will finish executing, but no subsequent tasks will run. This happens because the job checks its cancellation status between tasks.

To cancel a specific job, use the payload.jobs.cancelByID method with the job's ID:

1
await payload.jobs.cancelByID({
2
id: createdJob.id,
3
overrideAccess: true,
4
})

To cancel multiple jobs at once, use the payload.jobs.cancel method with a Where query:

1
await payload.jobs.cancel({
2
overrideAccess: true,
3
where: {
4
workflowSlug: {
5
equals: 'createPost',
6
},
7
},
8
})

From within a task or workflow handler, you can also cancel the current job by throwing a JobCancelledError:

1
throw new JobCancelledError('Job was cancelled')

Was this page helpful?

Next

Queues