Back to Payload

Job Schedules

docs/jobs-queue/schedules.mdx

3.84.116.4 KB
Original Source

Payload's schedule property lets you enqueue Jobs regularly according to a cron schedule - daily, weekly, hourly, or any custom interval. This is ideal for tasks or workflows that must repeat automatically and without manual intervention.

Scheduling Jobs differs significantly from running them:

  • Queueing: Scheduling only creates (enqueues) the Job according to your cron expression. It does not immediately execute any business logic.
  • Running: Execution happens separately through your Jobs runner - such as autorun, or manual invocation using payload.jobs.run() or the payload-jobs/run endpoint.

Use the schedule property specifically when you have recurring tasks or workflows. To enqueue a single Job to run once in the future, use the waitUntil property instead.

When to use Schedules

Here's a quick guide to help you choose the right approach:

ApproachUse CaseExample
ScheduleRecurring tasks that run automatically on a scheduleDaily reports, weekly emails, hourly syncs
waitUntilOne-time job in the futurePublish a post at 3pm tomorrow, send trial expiry email in 7 days
Collection HookJob triggered by document changesSend email when post is published, generate PDF when order is created
Manual QueueJob triggered by user action or API callUser clicks "Generate Report" button

Complete Setup Requirements

<Banner type="error"> **Critical:** To use scheduled jobs, you need TWO configurations working together. Configuring only one will not work. </Banner>

1. Schedule Configuration (This Page)

Define the schedule property on your task or workflow to automatically queue jobs:

ts
{
  slug: 'myTask',
  schedule: [{ cron: '0 9 * * *', queue: 'daily' }],
  handler: async () => { /* ... */ }
}

This handles the QUEUING—it adds jobs to the database on a schedule.

2. Runner Configuration (See Queues)

Configure how queued jobs are executed. For dedicated servers, use bin scripts (recommended):

sh
# Recommended: Bin script runs in separate process
pnpm payload jobs:run --cron "* * * * *" --queue daily --handle-schedules

Or use autoRun (alternative):

ts
jobs: {
  autoRun: [{ cron: '* * * * *', queue: 'daily' }]
}

This handles the RUNNING—it executes the job handler functions.

Quick Checklist

Before wondering why your scheduled jobs aren't working, verify:

  • Task has schedule property? → Jobs will be queued automatically
  • Config has autoRun or another runner? → Queued jobs will be executed
  • Both use the same queue name? → Jobs will flow from schedule to execution
  • Not on serverless platform?autoRun requires a long-running server

If any checkbox is missing, your scheduled jobs won't work properly.

Example comparison:

ts
// Bad practice - Using schedule for one-time future job
schedule: [{ cron: '0 15 * * *', queue: 'default' }] // Runs every day at 3pm

// Best practice - Use waitUntil for one-time future job
await payload.jobs.queue({
  task: 'publishPost',
  input: { postId: '123' },
  waitUntil: new Date('2024-12-25T15:00:00Z'), // Runs once at this specific time
})

// Best practice - Use schedule for recurring jobs
schedule: [{ cron: '0 0 * * *', queue: 'nightly' }] // Runs every day at midnight

Handling schedules

Something needs to actually trigger the scheduling of jobs (execute the scheduling lifecycle seen below). There are several ways to handle scheduling:

For dedicated servers, the recommended approach is to use Payload's bin scripts. This runs the scheduling logic in a separate process from your Next.js server.

Combined scheduling and running (recommended):

sh
# This will both schedule jobs according to the configuration AND run them
pnpm payload jobs:run --cron "*/5 * * * *" --queue myQueue --handle-schedules

Schedule-only (if you want separate processes for scheduling vs running):

sh
# This only handles scheduling - you'll need a separate process to run jobs
pnpm payload jobs:handle-schedules --cron "*/5 * * * *" --queue myQueue
# or for all queues:
pnpm payload jobs:handle-schedules --cron "*/5 * * * *" --all-queues

Benefits of bin scripts:

  • Separate process from Next.js server
  • Easy to deploy as independent workers
  • Simple to scale by running multiple instances
  • No impact on Next.js server performance

autoRun (Alternative for Dedicated Servers)

By default, the jobs.autoRun configuration handles scheduling for the queue specified in the autoRun configuration:

ts
jobs: {
  autoRun: [
    {
      cron: '*/5 * * * *',
      queue: 'default',
      // disableScheduling: false (default) - schedules are handled automatically
    },
  ],
}

You can disable this behavior by setting disableScheduling: true in your autoRun configuration if you want to handle scheduling separately.

API Endpoint (For Serverless)

For serverless environments, you can trigger scheduling via the /api/payload-jobs/run endpoint (which handles scheduling by default) or use the dedicated /api/payload-jobs/handle-schedules endpoint:

ts
// This endpoint handles both scheduling and running
await fetch('/api/payload-jobs/run?queue=myQueue')

// Or disable scheduling if you want to handle it separately
await fetch('/api/payload-jobs/run?queue=myQueue&disableScheduling=true')

// Dedicated scheduling endpoint
await fetch('/api/payload-jobs/handle-schedules?queue=myQueue')

Local API (For Programmatic Control)

You can also handle scheduling programmatically:

ts
await payload.jobs.handleSchedules()

Defining schedules on Tasks or Workflows

Schedules are defined using the schedule property:

ts
export type ScheduleConfig = {
  cron: string // required, supports seconds precision
  queue: string // required, the queue to push Jobs onto
  hooks?: {
    // Optional hooks to customize scheduling behavior
    beforeSchedule?: BeforeScheduleFn
    afterSchedule?: AfterScheduleFn
  }
}

Example schedule

The following example demonstrates scheduling a Job to enqueue every day at midnight:

ts
import type { TaskConfig } from 'payload'

export const SendDigestEmail: TaskConfig<'SendDigestEmail'> = {
  slug: 'SendDigestEmail',
  schedule: [
    {
      cron: '0 0 * * *', // Every day at midnight
      queue: 'nightly',
    },
  ],
  handler: async () => {
    await sendDigestToAllUsers()
  },
}

This configuration only queues the Job - it does not execute it immediately. To actually run the queued Job, you have several options:

Option 1: Bin script (recommended for dedicated servers):

sh
# This will both schedule and run jobs from the nightly queue
pnpm payload jobs:run --cron "* * * * *" --queue nightly --handle-schedules

Option 2: autoRun (alternative for dedicated servers):

ts
export default buildConfig({
  jobs: {
    autoRun: [
      {
        cron: '* * * * *', // Runs every minute
        queue: 'nightly',
      },
    ],
    tasks: [SendDigestEmail],
  },
})

Option 3: API endpoint (for serverless platforms):

Configure Vercel Cron or similar to trigger:

GET /api/payload-jobs/run?queue=nightly

With any of these options, Payload's scheduler will automatically enqueue the job into the nightly queue every day at midnight, and the runner will check the nightly queue every minute to execute any Jobs that are due to run.

Scheduling lifecycle

Here's how the scheduling process operates in detail:

  1. Cron evaluation: Payload (or your external trigger in manual mode) identifies which schedules are due to run. To do that, it will read the payload-jobs-stats global which contains information about the last time each scheduled task or workflow was run.
  2. BeforeSchedule hook:
    • The default beforeSchedule hook checks how many active or runnable jobs of the same type that have been queued by the scheduling system currently exist. If such a job exists, it will skip scheduling a new one.
    • You can provide your own beforeSchedule hook to customize this behavior. For example, you might want to allow multiple overlapping Jobs or dynamically set the Job input data.
  3. Enqueue Job: Payload queues up a new job. This job will have waitUntil set to the next scheduled time based on the cron expression.
  4. AfterSchedule hook:
    • The default afterSchedule hook updates the payload-jobs-stats global metadata with the last scheduled time for the Job.
    • You can provide your own afterSchedule hook to it for custom logging, metrics, or other post-scheduling actions.

Customizing concurrency and input

You may want more control over concurrency or dynamically set Job inputs at scheduling time. For instance, allowing multiple overlapping Jobs to be scheduled, even if a previously scheduled job has not completed yet, or preparing dynamic data to pass to your Job handler:

ts
import { countRunnableOrActiveJobsForQueue } from 'payload'

schedule: [
  {
    cron: '* * * * *', // every minute
    queue: 'reports',
    hooks: {
      beforeSchedule: async ({ queueable, req }) => {
        const runnableOrActiveJobsForQueue =
          await countRunnableOrActiveJobsForQueue({
            queue: queueable.scheduleConfig.queue,
            req,
            taskSlug: queueable.taskConfig?.slug,
            workflowSlug: queueable.workflowConfig?.slug,
            onlyScheduled: true,
          })

        // Allow up to 3 simultaneous scheduled jobs and set dynamic input
        return {
          shouldSchedule: runnableOrActiveJobsForQueue < 3,
          input: { text: 'Hi there' },
        }
      },
    },
  },
]

This allows fine-grained control over how many Jobs can run simultaneously and provides dynamically computed input values each time a Job is scheduled.

Scheduling in serverless environments

On serverless platforms, scheduling must be triggered externally since Payload does not automatically run cron schedules in ephemeral environments. You have two main ways to trigger scheduling manually:

  • Invoke via Payload's API: payload.jobs.handleSchedules()
  • Use the REST API endpoint: /api/payload-jobs/handle-schedules
  • Use the run endpoint, which also handles scheduling by default: GET /api/payload-jobs/run

For example, on Vercel, you can set up a Vercel Cron to regularly trigger scheduling:

  • Vercel Cron Job: Configure Vercel Cron to periodically call GET /api/payload-jobs/handle-schedules. If you would like to auto-run your scheduled jobs as well, you can use the GET /api/payload-jobs/run endpoint.

Once Jobs are queued, their execution depends entirely on your configured runner setup (e.g., autorun, or manual invocation).

Common Schedule Patterns

Here are typical cron expressions for common scheduling needs:

ts
// Every minute
schedule: [{ cron: '* * * * *', queue: 'frequent' }]

// Every 5 minutes
schedule: [{ cron: '*/5 * * * *', queue: 'default' }]

// Every hour at minute 0
schedule: [{ cron: '0 * * * *', queue: 'hourly' }]

// Every day at midnight (00:00)
schedule: [{ cron: '0 0 * * *', queue: 'nightly' }]

// Every day at 2:30 AM
schedule: [{ cron: '30 2 * * *', queue: 'nightly' }]

// Every Monday at 9:00 AM
schedule: [{ cron: '0 9 * * 1', queue: 'weekly' }]

// First day of every month at midnight
schedule: [{ cron: '0 0 1 * *', queue: 'monthly' }]

// Every weekday (Mon-Fri) at 8:00 AM
schedule: [{ cron: '0 8 * * 1-5', queue: 'weekdays' }]

// Every 30 seconds (with seconds precision)
schedule: [{ cron: '*/30 * * * * *', queue: 'frequent' }]

Cron format reference:

* * * * * *
│ │ │ │ │ │
│ │ │ │ │ └─ Day of week (0-7, 0 and 7 = Sunday)
│ │ │ │ └─── Month (1-12)
│ │ │ └───── Day of month (1-31)
│ │ └─────── Hour (0-23)
│ └───────── Minute (0-59)
└─────────── Second (0-59, optional)

Real-World Examples

Daily digest email:

ts
export const DailyDigestTask: TaskConfig<'DailyDigest'> = {
  slug: 'DailyDigest',
  schedule: [
    {
      cron: '0 7 * * *', // Every day at 7:00 AM
      queue: 'emails',
    },
  ],
  handler: async ({ req }) => {
    const users = await req.payload.find({
      collection: 'users',
      where: { digestEnabled: { equals: true } },
    })

    for (const user of users.docs) {
      await sendDigestEmail(user.email)
    }

    return { output: { emailsSent: users.docs.length } }
  },
}

Weekly report generation:

ts
export const WeeklyReportTask: TaskConfig<'WeeklyReport'> = {
  slug: 'WeeklyReport',
  schedule: [
    {
      cron: '0 9 * * 1', // Every Monday at 9:00 AM
      queue: 'reports',
    },
  ],
  handler: async ({ req }) => {
    const report = await generateWeeklyReport()
    await req.payload.create({
      collection: 'reports',
      data: report,
    })

    return { output: { reportId: report.id } }
  },
}

Hourly data sync:

ts
export const SyncDataTask: TaskConfig<'SyncData'> = {
  slug: 'SyncData',
  schedule: [
    {
      cron: '0 * * * *', // Every hour
      queue: 'sync',
    },
  ],
  handler: async ({ req }) => {
    const data = await fetchFromExternalAPI()
    await req.payload.create({
      collection: 'synced-data',
      data,
    })

    return { output: { itemsSynced: data.length } }
  },
}

Troubleshooting Schedules

Here are a few things to check when scheduled jobs are not being queued:

Is schedule handling enabled?

ts
// Make sure autoRun doesn't disable scheduling
jobs: {
  autoRun: [
    {
      cron: '*/5 * * * *',
      queue: 'default',
      disableScheduling: false, // Should be false or omitted
    },
  ],
}

Is the cron expression valid?

ts
// Invalid cron - 6 fields (with seconds) but missing day of week
schedule: [{ cron: '0 0 * * *', queue: 'default' }] // Missing 6th field

// Valid cron - 5 fields (standard format)
schedule: [{ cron: '0 0 * * *', queue: 'default' }]

// Valid cron - 6 fields (with seconds)
schedule: [{ cron: '0 0 0 * * *', queue: 'default' }]

Test your cron expressions at crontab.guru (for 5-field format).

Check the payload-jobs-stats global

ts
const stats = await payload.findGlobal({
  slug: 'payload-jobs-stats',
})

console.log(stats.lastScheduled) // Check when each task was last scheduled

Scheduled Jobs queued but not running

This means scheduling is working, but execution isn't. See the Queues troubleshooting section.

Jobs running at wrong times

Issue: Job scheduled for midnight but runs immediately

This happens when waitUntil isn't set properly. Check your schedule config:

ts
// The schedule property only queues the job
// The autoRun picks it up and runs it
schedule: [{ cron: '0 0 * * *', queue: 'nightly' }]

// Make sure autoRun checks the queue frequently enough
autoRun: [
  {
    cron: '* * * * *', // Check every minute
    queue: 'nightly',
  },
]

Multiple instances of the same scheduled job

By default, Payload prevents duplicate scheduled jobs. If you're seeing duplicates:

Are you running multiple servers without coordination?

If multiple servers are handling schedules, they might each queue jobs. Solution: Only enable schedule handling on one server:

ts
// Server 1 (handles schedules)
jobs: {
  shouldAutoRun: () => process.env.HANDLE_SCHEDULES === 'true',
  autoRun: [/* ... */],
}

// Server 2 (just processes jobs, no scheduling)
jobs: {
  shouldAutoRun: () => process.env.HANDLE_SCHEDULES !== 'true',
  autoRun: [{ disableScheduling: true }],
}

Custom beforeSchedule hook

If you have a custom beforeSchedule hook, make sure it properly checks for existing jobs:

ts
import { countRunnableOrActiveJobsForQueue } from 'payload'

hooks: {
  beforeSchedule: async ({ queueable, req }) => {
    const count = await countRunnableOrActiveJobsForQueue({
      queue: queueable.scheduleConfig.queue,
      req,
      taskSlug: queueable.taskConfig?.slug,
      onlyScheduled: true,
    })

    return {
      shouldSchedule: count === 0, // Only schedule if no jobs exist
    }
  },
}