BuildWithMatija
Get In Touch
  1. Home
  2. Blog
  3. Payload
  4. How to Implement Payload Jobs for Background Operations in Next.js on Vercel

How to Implement Payload Jobs for Background Operations in Next.js on Vercel

How to set up Payload's job queue on Vercel with after(), waitUntil, and cron for reliable background processing

28th May 2025·Updated on:21st March 2026·MŽMatija Žiberna·
Payload
How to Implement Payload Jobs for Background Operations in Next.js on Vercel

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

Related Posts:

  • •How to Build a CSV Product Import System with Payload Queues
  • •How to Send Email Notifications in Payload CMS Using the Native Plugin
  • •How to Update Schema in Production with Payload CMS Without Losing Data

How to Implement Payload Jobs for Background Operations in Next.js on Vercel

Payload CMS has a built-in job queue that lets you offload heavy work — image processing, data syncing, email sending — to background tasks that run without blocking your users. On Vercel, where serverless functions terminate after sending a response, you need a specific pattern to make this work: queue a job, trigger it with after() or waitUntil for immediate execution, and set up a Vercel cron as a safety net. This guide walks through the full setup, from defining tasks in payload.config.ts to configuring cron authentication and debugging stuck jobs.

I ran into this problem on a client project where media uploads needed to trigger gallery syncs across multiple pages. The afterChange hook was doing the sync inline, freezing the upload for 5+ seconds while it crawled every page looking for gallery blocks. On Vercel, that meant timeouts and a terrible editing experience. Payload's job queue solved it, but the docs don't cover the Vercel-specific patterns you need to make it production-ready.

Quick Reference

ApproachWhen to UseNext.js Version
after() from next/serverImmediate background work triggered by a user action15.1+
waitUntil from @vercel/functionsSame as above, for older Next.js versionsAny
Vercel Cron + /api/payload-jobs/runScheduled cleanup, batch processing, retry safety netAny
Payload autoRunSelf-hosted environments with persistent processesN/A on Vercel

If you are on Next.js 15.1+, use after(). If you are on an older version, use waitUntil. Both allow your serverless function to send a response immediately and continue running code in the background. The Vercel cron acts as a fallback that picks up any jobs that were queued but never executed.

Essential Reading

Before diving into the implementation, read these official Payload documentation pages:

  • Jobs Queue Overview — how the job system works under the hood
  • Tasks Documentation — defining tasks, input schemas, and handlers
  • Queues Documentation — managing queues and processing jobs

Understanding after() and waitUntil

Vercel's serverless functions normally terminate the moment a response is sent. Any async work still running gets killed. This is why you can't just fire off a payload.jobs.run() call after returning a response — the function shuts down before the job finishes.

Next.js 15.1 introduced after() from next/server, which tells the runtime to keep the function alive after the response is sent. Vercel's waitUntil from @vercel/functions does the same thing for older Next.js versions.

Here is the pattern with after():

// File: src/app/api/example/route.ts
import { after } from 'next/server'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'

export async function POST(request: Request) {
  const payload = await getPayload({ config })
  const data = await request.json()

  // Queue the job
  await payload.jobs.queue({
    task: 'myBackgroundTask',
    input: { id: data.id },
  })

  // Tell Next.js to keep the function alive after responding
  after(async () => {
    await payload.jobs.run({ limit: 10, queue: 'default' })
  })

  // User gets an immediate response
  return NextResponse.json({ status: 'queued' })
}

The after() callback runs after the response is sent to the client. The function stays alive long enough for payload.jobs.run() to process the queued job. If the function terminates before the job finishes (due to Vercel's execution time limit), the job stays queued and the cron picks it up later.

For older Next.js versions, the equivalent pattern uses waitUntil:

// File: src/app/api/example/route.ts
import { waitUntil } from '@vercel/functions'
import { NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'

export async function POST(request: Request) {
  const payload = await getPayload({ config })
  const data = await request.json()

  await payload.jobs.queue({
    task: 'myBackgroundTask',
    input: { id: data.id },
  })

  // waitUntil extends function lifetime after response
  waitUntil(
    payload.jobs.run({ limit: 10, queue: 'default' })
  )

  return NextResponse.json({ status: 'queued' })
}

The key difference: after() is a Next.js framework feature that works across deployment targets. waitUntil is a Vercel platform primitive. On Vercel with Next.js 15.1+, after() is the recommended choice.

The Architecture

Here is the full flow we are building:

User triggers action (upload, form submit, etc.)
    |
    v
afterChange hook queues a Payload job
    |
    v
after() runs payload.jobs.run() in background
    |
    +---> Success: job completes, user already has their response
    |
    +---> Function terminated early: job stays queued
                                        |
                                        v
                              Vercel cron hits /api/payload-jobs/run
                              every minute and picks up pending jobs

This gives you two layers of execution. The after() call handles the fast path — most jobs complete within a few seconds. The cron handles the safety net — if the function was killed, the job is still in the database waiting to be processed.

Step 1: Configure Payload Jobs

Start by defining your task in payload.config.ts. The task specifies what inputs it expects and what the handler does with them.

// File: payload.config.ts
import { buildConfig } from 'payload'
import type { PayloadRequest } from 'payload'

export default buildConfig({
  // ... your existing config

  jobs: {
    // Authentication for the job runner endpoint
    access: {
      run: ({ req }: { req: PayloadRequest }): boolean => {
        // Allow authenticated admin users
        if (req.user) return true

        // Allow Vercel cron with CRON_SECRET
        const secret = process.env.CRON_SECRET
        if (!secret) return false

        const authHeader = req.headers.get('authorization')
        return authHeader === `Bearer ${secret}`
      },
    },

    tasks: [
      {
        slug: 'syncMediaToGalleries',
        label: 'Sync Media to Gallery Blocks',
        inputSchema: [
          {
            name: 'mediaId',
            type: 'text',
            required: true,
          },
        ],
        handler: async ({ input, req }) => {
          req.payload.logger.info(
            `Starting gallery sync for media ${input.mediaId}`,
          )

          // Verify the media document exists (with retries for eventual consistency)
          let mediaDoc
          let retries = 3

          while (retries > 0) {
            try {
              mediaDoc = await req.payload.findByID({
                collection: 'media',
                id: input.mediaId,
              })
              break
            } catch (error) {
              retries--
              if (retries > 0) {
                await new Promise((resolve) => setTimeout(resolve, 1000))
              } else {
                throw new Error(
                  `Media ${input.mediaId} not found after retries`,
                )
              }
            }
          }

          if (!mediaDoc) {
            req.payload.logger.warn(
              `Media ${input.mediaId} not found, skipping`,
            )
            return { output: {} }
          }

          // Find pages with gallery blocks that have auto-sync enabled
          const pages = await req.payload.find({
            collection: 'pages',
            depth: 2,
            limit: 1000,
          })

          let pagesUpdated = 0
          let galleriesUpdated = 0

          for (const page of pages.docs) {
            if (!page.layout || !Array.isArray(page.layout)) continue

            let hasUpdates = false
            const updatedLayout = page.layout.map((block: any) => {
              if (
                block.blockType === 'gallery' &&
                block.autoSyncMedia === true
              ) {
                if (!block.images) block.images = []

                const alreadyExists = block.images.some(
                  (id: string) => id === input.mediaId,
                )

                if (!alreadyExists) {
                  block.images.push(input.mediaId)
                  hasUpdates = true
                  galleriesUpdated++
                }
              }
              return block
            })

            if (hasUpdates) {
              await req.payload.update({
                collection: 'pages',
                id: page.id,
                data: { layout: updatedLayout },
                depth: 0,
                overrideAccess: true,
              })
              pagesUpdated++
            }
          }

          req.payload.logger.info(
            `Gallery sync complete: ${pagesUpdated} pages, ${galleriesUpdated} galleries updated`,
          )

          return {
            output: { pagesUpdated, galleriesUpdated, mediaId: input.mediaId },
          }
        },
      },
    ],
  },

  // ... rest of your config
})

A few things to note in this configuration. The jobs.access.run function handles authentication at the Payload config level. This means you do not need to duplicate auth logic in every route handler — Payload checks it before running any jobs. The retry loop in the handler accounts for eventual consistency: when a media document is freshly created, it might not be immediately available in the database by the time the job runs.

The overrideAccess: true flag on the page update bypasses access control because this is a system-level operation running in a background context where there is no authenticated user session.

Since Payload v3.49.0, you can also configure concurrency control for autoRun to prevent job overload. On Vercel, autoRun does not apply (there is no persistent process), so we rely on HTTP triggers and cron instead.

Step 2: Create the Collection Hook

The hook fires when new media is uploaded, queues a background job, and uses after() to trigger immediate execution without blocking the upload response.

// File: src/collections/Media/hooks/syncGalleryBlocks.ts
import { CollectionAfterChangeHook } from 'payload'
import { after } from 'next/server'

export const syncGalleryBlocks: CollectionAfterChangeHook = async ({
  doc,
  req,
  operation,
}) => {
  if (operation !== 'create') {
    return doc
  }

  try {
    // Queue the job
    const job = await req.payload.jobs.queue({
      task: 'syncMediaToGalleries',
      input: { mediaId: doc.id },
    })

    req.payload.logger.info(
      `Queued gallery sync job ${job.id} for media ${doc.id}`,
    )

    // Run the job in the background after the response is sent
    after(async () => {
      try {
        await req.payload.jobs.run({ limit: 10, queue: 'default' })
      } catch (error) {
        req.payload.logger.error(
          `Background job execution failed for media ${doc.id}: ${(error as Error).message}`,
        )
      }
    })
  } catch (error) {
    req.payload.logger.error(
      `Failed to queue gallery sync for media ${doc.id}: ${(error as Error).message}`,
    )
  }

  return doc
}

This is a significant improvement over the setTimeout pattern. With after(), the response returns to the user immediately. The job queue call and the after() callback both happen without blocking. If the background execution fails or the function is terminated, the job remains in the queue for the cron to pick up.

The operation !== 'create' check ensures this only runs for new uploads. Updates to existing media (like changing alt text) do not trigger a gallery sync.

If you are on a Next.js version older than 15.1, replace the after() import and call with waitUntil from @vercel/functions:

// File: src/collections/Media/hooks/syncGalleryBlocks.ts (pre-15.1)
import { waitUntil } from '@vercel/functions'

// Inside the hook, replace after() with:
waitUntil(
  req.payload.jobs.run({ limit: 10, queue: 'default' }).catch((error) => {
    req.payload.logger.error(
      `Background job execution failed: ${(error as Error).message}`,
    )
  }),
)

Step 3: Register the Hook

Add the hook to your Media collection configuration.

// File: src/collections/Media.ts
import { CollectionConfig } from 'payload'
import { syncGalleryBlocks } from './Media/hooks/syncGalleryBlocks'

export const Media: CollectionConfig = {
  slug: 'media',
  labels: {
    singular: 'Media',
    plural: 'Media',
  },
  hooks: {
    afterChange: [syncGalleryBlocks],
  },
  upload: {
    // ... your upload config
  },
  fields: [
    // ... your fields
  ],
}

The afterChange hook fires after the media document is saved to the database. This guarantees the media ID is valid and available when the background job looks it up.

Step 4: Create the Job Runner Endpoint

This endpoint processes queued jobs. It serves two purposes: the Vercel cron calls it on a schedule, and you can call it manually for testing or immediate processing.

// File: src/app/api/payload-jobs/run/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'

export async function GET(request: NextRequest) {
  const startTime = Date.now()

  try {
    const payload = await getPayload({ config })

    const result = await payload.jobs.run({
      limit: 10,
      queue: 'default',
    })

    const executionTime = Date.now() - startTime

    payload.logger.info(
      `Job runner completed in ${executionTime}ms: ${JSON.stringify(result)}`,
    )

    return NextResponse.json({
      success: true,
      result,
      executionTimeMs: executionTime,
    })
  } catch (error) {
    const executionTime = Date.now() - startTime
    console.error('Error running jobs:', error)

    return NextResponse.json(
      {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error',
        executionTimeMs: executionTime,
      },
      { status: 500 },
    )
  }
}

export async function POST(request: NextRequest) {
  return GET(request)
}

Notice there is no manual auth check in this route. Authentication is handled by the jobs.access.run function in payload.config.ts (Step 1). When payload.jobs.run() is called, Payload checks that access function before processing any jobs. Vercel sends the CRON_SECRET as an Authorization: Bearer header automatically when triggering cron endpoints.

The endpoint supports both GET and POST because Vercel cron uses GET requests by default, while manual triggers or webhook integrations might use POST.

The limit: 10 parameter processes up to 10 jobs per invocation. This prevents a single cron tick from running too long and hitting Vercel's function timeout. If more than 10 jobs are queued, the next cron tick picks up the rest.

Step 5: Configure Vercel Cron Jobs

Add a vercel.json file to your project root (or update your existing one) with a cron schedule:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    {
      "path": "/api/payload-jobs/run",
      "schedule": "*/1 * * * *"
    }
  ]
}

The */1 * * * * schedule runs the job endpoint every minute. For most applications this provides fast enough pickup. If your jobs are less time-sensitive, you can reduce the frequency:

ScheduleFrequencyGood For
*/1 * * * *Every minuteReal-time sync, notifications
*/5 * * * *Every 5 minutesEmail digests, report generation
0 * * * *Every hourData aggregation, cleanup tasks
0 0 * * *Daily at midnightBackups, daily summaries

Vercel automatically sends a CRON_SECRET header when it triggers cron endpoints, provided you have set the CRON_SECRET environment variable in your project settings. There is a known issue (tracked on GitHub) where Vercel occasionally fails to send the header. If you encounter 401 errors on cron runs, check the Vercel function logs to confirm the header is present. A workaround is to also check for Vercel's x-vercel-cron header as a secondary validation.

Vercel's cron frequency limits depend on your plan. Hobby plans support cron jobs running once per day. Pro plans support up to every minute. Check Vercel's pricing page for current limits.

Step 6: Set Up the Gallery Block

For the gallery auto-sync use case, you need a block with an autoSyncMedia toggle and an images field.

// File: src/blocks/general/Gallery/config.ts
import type { Block } from 'payload'

const GalleryBlock: Block = {
  slug: 'gallery',
  interfaceName: 'GalleryBlock',
  labels: {
    singular: 'Gallery',
    plural: 'Galleries',
  },
  fields: [
    {
      name: 'autoSyncMedia',
      type: 'checkbox',
      label: 'Auto-sync new media uploads',
      defaultValue: false,
      admin: {
        description:
          'When enabled, new uploaded images will automatically be added to this gallery',
      },
    },
    {
      name: 'images',
      type: 'upload',
      relationTo: 'media',
      hasMany: true,
      label: 'Gallery Images',
    },
  ],
}

export default GalleryBlock

The autoSyncMedia checkbox gives editors control over which galleries receive new uploads. The default is false, so galleries only auto-sync when explicitly opted in. The hasMany: true on the images field allows multiple media documents per gallery.

Step 7: Environment Variables

Set these in your Vercel project dashboard under Settings > Environment Variables:

# Required for cron authentication in production
CRON_SECRET=your-secure-random-string-here

# Your deployment URL
NEXT_PUBLIC_SERVER_URL=https://your-domain.vercel.app

Generate the CRON_SECRET with a cryptographically secure method — at least 32 characters. You can generate one with openssl rand -base64 32.

In local development, you can skip CRON_SECRET entirely. The jobs.access.run function in your Payload config returns false when no secret is set, but authenticated admin users can still trigger jobs through the admin panel.

Step 8: Testing

Test the full flow by uploading a media file through the Payload admin panel and checking the logs. You can also trigger the job runner directly:

# Test locally
curl -X GET http://localhost:3000/api/payload-jobs/run

# Test on Vercel (with auth)
curl -X GET https://your-domain.vercel.app/api/payload-jobs/run \
  -H "Authorization: Bearer your-cron-secret"

Watch the Vercel function logs (or your local terminal) for output like:

Starting gallery sync for media abc123
Gallery sync complete: 2 pages, 3 galleries updated
Job runner completed in 1250ms

If you want a more thorough test, create a script:

// File: scripts/test-gallery-sync.js
const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
const CRON_SECRET = process.env.CRON_SECRET
const MEDIA_ID = process.argv[2]

if (!MEDIA_ID) {
  console.error('Usage: node scripts/test-gallery-sync.js <mediaId>')
  process.exit(1)
}

async function test() {
  console.log(`Testing gallery sync for media: ${MEDIA_ID}`)

  const response = await fetch(`${SERVER_URL}/api/payload-jobs/run`, {
    method: 'GET',
    headers: {
      ...(CRON_SECRET && { Authorization: `Bearer ${CRON_SECRET}` }),
    },
  })

  const result = await response.json()
  console.log('Result:', JSON.stringify(result, null, 2))
}

test().catch(console.error)

Run it with node scripts/test-gallery-sync.js YOUR_MEDIA_ID.

Troubleshooting

Jobs not executing

The most common cause is that after() is not keeping the function alive. Make sure you are on Next.js 15.1+ and that after() is imported from next/server. If you are on an older version, use waitUntil from @vercel/functions instead. Also verify that the job runner endpoint (/api/payload-jobs/run) is returning 200 when hit directly.

"Task not found" or jobs stuck in pending

If you queue a job with a task slug that does not match any task registered in payload.config.ts, the job will be queued in the database but never execute. Double-check that the slug in your payload.jobs.queue() call matches the slug in your jobs.tasks array exactly.

Vercel cron returning 401 Unauthorized

Verify that CRON_SECRET is set in your Vercel environment variables and that the jobs.access.run function in your Payload config checks the Authorization header correctly. There is a known Vercel issue where the CRON_SECRET header is occasionally not sent. Check the function logs to confirm. As a workaround, you can add a secondary check for the x-vercel-cron header:

// Fallback check in jobs.access.run
const isVercelCron = req.headers.get('x-vercel-cron') === '1'
if (isVercelCron && secret) {
  return true
}

Jobs stuck in "processing" state

This happens when a function is terminated mid-execution (timeout, crash, deployment). The job is marked as "processing" and never completes. Check Vercel function logs for timeout errors. To clear stuck jobs, you can query the payload-jobs collection in your database and reset their status, or use payload.jobs.run() with appropriate options to retry them.

Vercel function timeout

Vercel's default function timeout is 10 seconds on the Hobby plan and up to 300 seconds on Pro. If your job handler takes longer than the timeout, the function is killed. Keep job handlers fast by processing one item at a time and queuing separate jobs for batch operations. The limit: 10 parameter on payload.jobs.run() also helps — it processes at most 10 jobs per invocation, leaving the rest for the next cron tick.

Gallery images not appearing

Check that the gallery block has autoSyncMedia set to true in the Payload admin panel. Verify the media ID exists by checking the media collection. Look at the function logs for the "Gallery sync complete" message to confirm the job ran and which pages were updated.

Frequently Asked Questions

Can I use Payload's autoRun feature on Vercel?

No. autoRun relies on a persistent Node.js process that continuously polls for new jobs. Vercel's serverless functions are ephemeral — they spin up for a request and shut down afterward. Use the HTTP endpoint + cron pattern described in this guide instead.

What happens if the same job is queued twice?

Payload does not deduplicate jobs automatically. If you need deduplication, add a check in your hook before queuing (for example, query the payload-jobs collection for an existing pending job with the same input), or handle idempotency in the task handler itself (the gallery sync example already does this by checking if the media ID already exists in the gallery).

Can I use this pattern for tasks other than gallery sync?

Absolutely. The pattern (queue in hook, execute via after(), cron as safety net) works for any background operation: sending emails, generating thumbnails, syncing data to external APIs, running reports. Define a new task in payload.config.ts and queue it from any hook or API route.

How do I monitor job execution in production?

Vercel's function logs show all payload.logger output. You can also query the payload-jobs collection through the Payload admin panel or REST API to see job statuses, execution times, and error messages.

Should I use Inngest or Trigger.dev instead of Payload jobs?

For simple background operations tightly coupled to your Payload data, the built-in job queue is the simplest option — no external services, no additional billing, no webhook configuration. Inngest and Trigger.dev are better choices when you need complex multi-step workflows, fan-out patterns, rate limiting across services, or jobs that run for minutes rather than seconds.

What You Have Built

This setup gives you non-blocking uploads where users get instant responses, automatic background processing via after() for the fast path, a Vercel cron safety net that catches any jobs missed by the immediate path, and centralized authentication through Payload's jobs.access.run config. The gallery auto-sync example demonstrates the pattern, but it applies to any background operation you need to run in a serverless Payload CMS deployment.

The full flow is: hook queues job, after() processes it immediately in the background, cron picks up stragglers every minute. Three layers of reliability with minimal code.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
8

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

No comments yet

Be the first to share your thoughts on this post!

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

How to Build a CSV Product Import System with Payload Queues
How to Build a CSV Product Import System with Payload Queues

12th August 2025

How to Send Email Notifications in Payload CMS Using the Native Plugin
How to Send Email Notifications in Payload CMS Using the Native Plugin

11th September 2025

How to Update Schema in Production with Payload CMS Without Losing Data
How to Update Schema in Production with Payload CMS Without Losing Data

13th August 2025

Table of Contents

  • How to Implement Payload Jobs for Background Operations in Next.js on Vercel
  • Quick Reference
  • Essential Reading
  • Understanding after() and waitUntil
  • The Architecture
  • Step 1: Configure Payload Jobs
  • Step 2: Create the Collection Hook
  • Step 3: Register the Hook
  • Step 4: Create the Job Runner Endpoint
  • Step 5: Configure Vercel Cron Jobs
  • Step 6: Set Up the Gallery Block
  • Step 7: Environment Variables
  • Step 8: Testing
  • Troubleshooting
  • Jobs not executing
  • "Task not found" or jobs stuck in pending
  • Vercel cron returning 401 Unauthorized
  • Jobs stuck in "processing" state
  • Vercel function timeout
  • Gallery images not appearing
  • Frequently Asked Questions
  • What You Have Built
On this page:
  • How to Implement Payload Jobs for Background Operations in Next.js on Vercel
  • Quick Reference
  • Essential Reading
  • Understanding after() and waitUntil
  • The Architecture
Build With Matija Logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit
  • Resources

    • Case Studies
    • How I Work
    • Blog
    • CMS Hub
    • E-commerce Hub
    • Dashboard

    Headless CMS

    • Payload CMS Developer
    • CMS Migration
    • Payload vs Sanity
    • Payload vs WordPress
    • Payload vs Contentful

    Get in Touch

    Ready to modernize your stack? Let's talk about what you're building.

    Book a discovery callContact me →
    © 2026BuildWithMatija•All rights reserved