- 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

Need Help Making the Switch?
Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.
Book Hourly AdvisoryRelated Posts:
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
| Approach | When to Use | Next.js Version |
|---|---|---|
after() from next/server | Immediate background work triggered by a user action | 15.1+ |
waitUntil from @vercel/functions | Same as above, for older Next.js versions | Any |
Vercel Cron + /api/payload-jobs/run | Scheduled cleanup, batch processing, retry safety net | Any |
Payload autoRun | Self-hosted environments with persistent processes | N/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:
| Schedule | Frequency | Good For |
|---|---|---|
*/1 * * * * | Every minute | Real-time sync, notifications |
*/5 * * * * | Every 5 minutes | Email digests, report generation |
0 * * * * | Every hour | Data aggregation, cleanup tasks |
0 0 * * * | Daily at midnight | Backups, 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.
Frequently Asked Questions
Comments
No comments yet
Be the first to share your thoughts on this post!


