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.

Tasks

You can register Tasks on the Payload config, and then create Jobs or Workflows that use them. Think of Tasks like tidy, isolated "functions that do one specific thing".

Payload Tasks can be configured to be automatically retried if they fail, which makes them valuable for "durable" workflows like AI applications where LLMs can return non-deterministic results, and might need to be retried.

Tasks can either be defined within the jobs.tasks array in your Payload config, or they can be defined inline within a workflow.

Defining tasks in the config

Simply add a task to the jobs.tasks array in your Payload config. A task consists of the following fields:

Option

Description

slug

Define a slug-based name for this job. This slug needs to be unique among both tasks and workflows.

handler

The function that should be responsible for running the job. You can either pass a string-based path to the job function file, or the job function itself. If you are using large dependencies within your job, you might prefer to pass the string path because that will avoid bundling large dependencies in your Next.js app. Passing a string path is an advanced feature that may require a sophisticated build pipeline in order to work.

inputSchema

Define the input field schema - Payload will generate a type for this schema.

interfaceName

You can use interfaceName to change the name of the interface that is generated for this task. By default, this is "Task" + the capitalized task slug.

outputSchema

Define the output field schema - Payload will generate a type for this schema.

label

Define a human-friendly label for this task.

onFail

Function to be executed if the task fails.

onSuccess

Function to be executed if the task succeeds.

retries

Specify the number of times that this step should be retried if it fails. If this is undefined, the task will either inherit the retries from the workflow or have no retries. If this is 0, the task will not be retried. By default, this is undefined.

concurrency

Control how jobs with the same concurrency key are handled. Jobs with the same key will run exclusively (one at a time). Requires jobs.enableConcurrencyControl: true to be set. See Concurrency Controls for details.

schedule

Define one or more schedules to automatically queue this task periodically. Each schedule requires a cron expression and a queue name. See Job Schedules for complete documentation.

The logic for the Task is defined in the handler - which can be defined as a function, or a path to a function. The handler will run once a worker picks up a Job that includes this task.

It should return an object with an output key, which should contain the output of the task as you've defined.

Example:

1
export default buildConfig({
2
// ...
3
jobs: {
4
tasks: [
5
{
6
// Configure this task to automatically retry
7
// up to two times
8
retries: 2,
9
10
// This is a unique identifier for the task
11
12
slug: 'createPost',
13
14
// These are the arguments that your Task will accept
15
inputSchema: [
16
{
17
name: 'title',
18
type: 'text',
19
required: true,
20
},
21
],
22
23
// These are the properties that the function should output
24
outputSchema: [
25
{
26
name: 'postID',
27
type: 'text',
28
required: true,
29
},
30
],
31
32
// This is the function that is run when the task is invoked
33
handler: async ({ input, job, req }) => {
34
const newPost = await req.payload.create({
35
collection: 'post',
36
req,
37
data: {
38
title: input.title,
39
},
40
})
41
return {
42
output: {
43
postID: newPost.id,
44
},
45
}
46
},
47
} as TaskConfig<'createPost'>,
48
],
49
},
50
})

Scheduling Tasks to Run Automatically

Tasks can be configured to run automatically on a schedule by adding the schedule property. This is useful for recurring operations like daily reports, periodic syncs, or scheduled cleanups.

How it works:

  1. The schedule property automatically queues jobs at specified times (no need to call payload.jobs.queue() manually)
  2. You still need to configure a job runner (like autoRun) to execute the queued jobs
  3. Both the schedule and runner must use the same queue name

Example:

1
export default buildConfig({
2
jobs: {
3
tasks: [
4
{
5
slug: 'dailyDigest',
6
7
// This automatically queues the task every day at 8 AM
8
schedule: [
9
{
10
cron: '0 8 * * *', // Every day at 8:00 AM
11
queue: 'daily', // Queue to add the job to
12
},
13
],
14
15
inputSchema: [
16
{
17
name: 'date',
18
type: 'date',
19
},
20
],
21
22
handler: async ({ req, input }) => {
23
// Send daily digest emails
24
const users = await req.payload.find({
25
collection: 'users',
26
where: { subscribed: { equals: true } },
27
})
28
29
for (const user of users.docs) {
30
await req.payload.sendEmail({
31
to: user.email,
32
subject: 'Your Daily Digest',
33
html: generateDigestHTML(user),
34
})
35
}
36
37
return {
38
output: {
39
emailsSent: users.docs.length,
40
date: input.date || new Date().toISOString(),
41
},
42
}
43
},
44
} as TaskConfig<'dailyDigest'>,
45
],
46
47
// Important: You also need to configure a runner to execute scheduled jobs
48
autoRun: [
49
{
50
cron: '* * * * *', // Check for jobs every minute
51
queue: 'daily', // Process jobs from 'daily' queue
52
limit: 10,
53
},
54
],
55
},
56
})

Key Points:

  • The schedule property automatically calls payload.jobs.queue() for you on the specified schedule
  • You can define multiple schedules per task by adding more objects to the schedule array
  • The cron field uses standard cron syntax (minute, hour, day, month, day-of-week)
  • Both schedule.queue and autoRun.queue must match for jobs to run
  • Scheduling is handled automatically by Payload's scheduler—no manual intervention needed

Common cron patterns:

1
// Every hour at minute 0
2
schedule: [{ cron: '0 * * * *', queue: 'hourly' }]
3
4
// Every day at midnight
5
schedule: [{ cron: '0 0 * * *', queue: 'nightly' }]
6
7
// Every Monday at 9 AM
8
schedule: [{ cron: '0 9 * * 1', queue: 'weekly' }]
9
10
// Every 5 minutes
11
schedule: [{ cron: '*/5 * * * *', queue: 'frequent' }]
12
13
// Every 3 seconds (extended cron syntax with seconds field)
14
schedule: [{ cron: '*/3 * * * * *', queue: 'realtime' }]

See Job Schedules for comprehensive scheduling documentation, including hooks, concurrency controls, and troubleshooting.

Common Task Patterns

Database Operations

Creating or updating documents based on other document changes:

1
{
2
slug: 'updateRelatedPosts',
3
retries: 2,
4
inputSchema: [
5
{
6
name: 'categoryId',
7
type: 'relationship',
8
relationTo: 'categories',
9
required: true,
10
},
11
],
12
handler: async ({ input, req }) => {
13
const posts = await req.payload.find({
14
collection: 'posts',
15
where: {
16
category: {
17
equals: input.categoryId,
18
},
19
},
20
})
21
22
// Update all posts in this category
23
for (const post of posts.docs) {
24
await req.payload.update({
25
collection: 'posts',
26
id: post.id,
27
data: {
28
categoryUpdatedAt: new Date().toISOString(),
29
},
30
})
31
}
32
33
return {
34
output: {
35
postsUpdated: posts.docs.length,
36
},
37
}
38
},
39
}

External API Calls

Calling third-party services without blocking your API:

1
{
2
slug: 'syncToThirdParty',
3
retries: 3,
4
inputSchema: [
5
{
6
name: 'documentId',
7
type: 'text',
8
required: true,
9
},
10
],
11
handler: async ({ input, req }) => {
12
const doc = await req.payload.findByID({
13
collection: 'documents',
14
id: input.documentId,
15
})
16
17
// Call external API
18
const response = await fetch('https://api.example.com/sync', {
19
method: 'POST',
20
headers: { 'Content-Type': 'application/json' },
21
body: JSON.stringify(doc),
22
})
23
24
if (!response.ok) {
25
throw new Error(`API error: ${response.statusText}`)
26
}
27
28
return {
29
output: {
30
synced: true,
31
apiResponse: await response.json(),
32
},
33
}
34
},
35
}

Conditional Failure

Sometimes you want to fail a task based on business logic:

1
{
2
slug: 'processPayment',
3
retries: 1,
4
inputSchema: [
5
{
6
name: 'orderId',
7
type: 'text',
8
required: true,
9
},
10
],
11
handler: async ({ input, req }) => {
12
const order = await req.payload.findByID({
13
collection: 'orders',
14
id: input.orderId,
15
})
16
17
// Intentionally fail if order is already processed
18
if (order.status === 'paid') {
19
throw new Error('Order already processed')
20
}
21
22
// Process payment...
23
24
return {
25
output: {
26
paymentId: 'payment-123',
27
},
28
}
29
},
30
}

Handling Task Failures

Tasks fail by throwing errors. When a task encounters any type of failure—whether it's an unexpected error, a validation issue, or a business logic violation—you should throw an error with a descriptive message.

1
handler: async ({ input, req }) => {
2
const order = await req.payload.findByID({
3
collection: 'orders',
4
id: input.orderId,
5
})
6
7
// Validation failure
8
if (input.amount !== order.total) {
9
throw new Error(
10
`Amount mismatch: expected ${order.total}, received ${input.amount}`,
11
)
12
}
13
14
// Business rule failure
15
if (order.status === 'cancelled') {
16
throw new Error('Cannot process payment for cancelled order')
17
}
18
19
// Conditional check
20
if (order.status === 'paid') {
21
throw new Error('Order already processed')
22
}
23
24
// Continue processing...
25
}

Preventing Job Retries

From within a task or workflow handler, you can prevent the entire job from being retried by throwing a JobCancelledError:

1
throw new JobCancelledError('Job was cancelled')

Accessing Failure Information

After a task fails, you can inspect the job to understand what went wrong:

1
const job = await payload.jobs.queue({
2
task: 'processPayment',
3
input: { orderId: '123', amount: 100 },
4
})
5
6
// Run the job
7
await payload.jobs.run()
8
9
// Check the job status
10
const completedJob = await payload.findByID({
11
collection: 'payload-jobs',
12
id: job.id,
13
})
14
15
// Check if job failed
16
if (completedJob.hasError) {
17
// Access the latest error that caused the job to fail
18
console.log(completedJob.error)
19
// This will contain the error message from the thrown error
20
21
// You can also check the job log to find specific tasks that errored
22
// Note: If the job was retried multiple times, there will be multiple erroring tasks in the log
23
const failedTasks = completedJob.log?.filter(
24
(entry) => entry.state === 'failed',
25
)
26
}

Understanding Task Execution

When a task runs

  1. The job is picked up from the queue by a worker
  2. The handler function executes with the provided input
  3. If successful, the output is stored and the job completes
  4. If it throws an error, the task will retry (up to retries count)
  5. After all retries are exhausted, the task and job fail

Advanced: Handler File Paths

In addition to defining handlers as functions directly provided to your Payload config, you can also pass an absolute path to where the handler is defined. If your task has large dependencies, and you are planning on executing your jobs in a separate process that has access to the filesystem, this could be a handy way to make sure that your Payload + Next.js app remains quick to compile and has minimal dependencies.

Keep in mind that this is an advanced feature that may require a sophisticated build pipeline, especially when using it in production or within Next.js, e.g. by calling opening the /api/payload-jobs/run endpoint. You will have to transpile the handler files separately and ensure they are available in the same location when the job is run. If you're using an endpoint to execute your jobs, it's recommended to define your handlers as functions directly in your Payload Config, or use import paths handlers outside of Next.js.

In general, this is an advanced use case. Here's how this would look:

payload.config.ts:

1
import { fileURLToPath } from 'node:url'
2
import path from 'path'
3
4
const filename = fileURLToPath(import.meta.url)
5
const dirname = path.dirname(filename)
6
7
export default buildConfig({
8
jobs: {
9
tasks: [
10
{
11
// ...
12
// The #createPostHandler is a named export within the `createPost.ts` file
13
handler:
14
path.resolve(dirname, 'src/tasks/createPost.ts') +
15
'#createPostHandler',
16
},
17
],
18
},
19
})

Then, the createPost file itself:

src/tasks/createPost.ts:

1
import type { TaskHandler } from 'payload'
2
3
export const createPostHandler: TaskHandler<'createPost'> = async ({
4
input,
5
job,
6
req,
7
}) => {
8
const newPost = await req.payload.create({
9
collection: 'post',
10
req,
11
data: {
12
title: input.title,
13
},
14
})
15
return {
16
output: {
17
postID: newPost.id,
18
},
19
}
20
}

Configuring task restoration

By default, if a task has passed previously and a workflow is re-run, the task will not be re-run. Instead, the output from the previous task run will be returned. This is to prevent unnecessary re-runs of tasks that have already passed.

You can configure this behavior through the retries.shouldRestore property. This property accepts a boolean or a function.

If shouldRestore is set to true, the task will only be re-run if it previously failed. This is the default behavior.

If shouldRestore is set to false, the task will be re-run even if it previously succeeded, ignoring the maximum number of retries.

If shouldRestore is a function, the return value of the function will determine whether the task should be re-run. This can be used for more complex restore logic, e.g you may want to re-run a task up to X amount of times and then restore it for consecutive runs, or only re-run a task if the input has changed.

Example:

1
export default buildConfig({
2
// ...
3
jobs: {
4
tasks: [
5
{
6
slug: 'myTask',
7
retries: {
8
shouldRestore: false,
9
},
10
// ...
11
} as TaskConfig<'myTask'>,
12
],
13
},
14
})

Example - determine whether a task should be restored based on the input data:

1
export default buildConfig({
2
// ...
3
jobs: {
4
tasks: [
5
{
6
slug: 'myTask',
7
inputSchema: [
8
{
9
name: 'someDate',
10
type: 'date',
11
required: true,
12
},
13
],
14
retries: {
15
shouldRestore: ({ input }) => {
16
if (new Date(input.someDate) > new Date()) {
17
return false
18
}
19
return true
20
},
21
},
22
// ...
23
} as TaskConfig<'myTask'>,
24
],
25
},
26
})

Nested tasks

You can run sub-tasks within an existing task, by using the tasks or inlineTask arguments passed to the task handler function:

1
export default buildConfig({
2
// ...
3
jobs: {
4
// It is recommended to set `addParentToTaskLog` to `true` when using nested tasks, so that the parent task is included in the task log
5
// This allows for better observability and debugging of the task execution
6
addParentToTaskLog: true,
7
tasks: [
8
{
9
slug: 'parentTask',
10
inputSchema: [
11
{
12
name: 'text',
13
type: 'text',
14
},
15
],
16
handler: async ({ input, req, tasks, inlineTask }) => {
17
await inlineTask('Sub Task 1', {
18
task: () => {
19
// Do something
20
return {
21
output: {},
22
}
23
},
24
})
25
26
await tasks.CreateSimple('Sub Task 2', {
27
input: { message: 'hello' },
28
})
29
30
return {
31
output: {},
32
}
33
},
34
} as TaskConfig<'parentTask'>,
35
],
36
},
37
})

Was this page helpful?

Next

Workflows