Next.js is designed for serverless and server-side rendering, which creates a genuine difficulty for tasks that need to run periodically or outside the request-response cycle. A cron job registered inside an API route may never run on time if the serverless function is not already warm. A long-running task may be terminated before it completes. Multiple serverless instances can cause the same job to run more than once.
Reliable background jobs in Next.js require understanding this constraint and choosing a setup that accounts for it. This guide covers the options, from simple node-cron on a persistent server to BullMQ workers backed by Redis, with deployment considerations for each.
What this covers:
Why Next.js's execution model complicates background jobs
Three scheduling approaches and when to use each
Running node-cron locally and in production
BullMQ workers with Redis for durable job queues
Vercel Cron Jobs for serverless-compatible scheduling
Failure handling and retries
Monitoring and observability
Why Next.js Makes Background Jobs Harder
A traditional Node.js server or a long-running process keeps the same memory and event loop alive across requests. A cron job registered with node-cron on startup will fire at the scheduled time regardless of whether a request is incoming.
Serverless platforms including Vercel work differently. Each API route invocation spins up a function instance on demand. Between requests, there is no running process. node-cron registered inside an API route does not help because the code that registers the cron job only runs when the route receives a request, and the scheduled task only fires while that function instance is alive.
The specific problems this creates:
Cold starts. A scheduled task that relies on the server being warm will silently fail if no requests have kept the instance alive.
Duplicate executions. If multiple serverless instances are active simultaneously (which happens under load), a cron job registered in shared code could run in each instance concurrently.
Timeout limits. Serverless functions have maximum execution times (typically 10 to 60 seconds depending on platform and plan). A long-running background job will be killed if it exceeds this limit.
The solution depends on the hosting environment and the nature of the task.
Approach 1: node-cron on a Persistent Server
node-cron is the simplest solution and works correctly on platforms that run a persistent Node.js process: a VPS, Fly.io, Railway, or Render with a long-running web service.
Install the package:
npm install node-cron
npm install --save-dev @types/node-cron
Create a scheduler module that runs alongside the Next.js server:
// src/lib/scheduler.ts
import cron from 'node-cron';
export function startScheduler(): void {
cron.schedule('0 * * * *', async () => {
console.log('Hourly task started:', new Date().toISOString());
try {
await syncExternalData();
} catch (err) {
console.error('Hourly task failed:', err);
}
});
cron.schedule('0 2 * * *', async () => {
console.log('Daily cleanup started:', new Date().toISOString());
try {
await cleanupOldLogs();
} catch (err) {
console.error('Daily cleanup failed:', err);
}
});
console.log('Scheduler initialised');
}
Call startScheduler() from a custom server entry point:
// server.ts
import { createServer } from 'http';
import { parse } from 'url';
import next from 'next';
import { startScheduler } from './src/lib/scheduler';
const app = next({ dev: process.env.NODE_ENV !== 'production' });
const handle = app.getRequestHandler();
app.prepare().then(() => {
startScheduler();
createServer((req, res) => {
const parsedUrl = parse(req.url!, true);
handle(req, res, parsedUrl);
}).listen(3000, () => {
console.log('Server ready on port 3000');
});
});
This approach is straightforward but requires a platform that keeps the process running. It is not suitable for Vercel.
Approach 2: BullMQ Workers with Redis
For tasks that are heavy, long-running, or need reliable exactly-once execution, a job queue with a dedicated worker process is the correct architecture. BullMQ uses Redis as a durable store for the job queue, which means jobs survive server restarts and are not lost if a worker crashes.
Install the dependencies:
npm install bullmq ioredis
Define the queue and worker in separate files:
// src/lib/queue.ts
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
export const emailQueue = new Queue('email', { connection });
export const reportQueue = new Queue('report', { connection });
// workers/email.worker.ts
import { Worker, Job } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
interface EmailJobData {
to: string;
subject: string;
body: string;
}
const worker = new Worker<EmailJobData>(
'email',
async (job: Job<EmailJobData>) => {
console.log(`Processing email job ${job.id} to ${job.data.to}`);
await sendEmail(job.data);
},
{
connection,
concurrency: 5,
}
);
worker.on('completed', (job) => {
console.log(`Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed:`, err.message);
});
Add jobs to the queue from API routes:
// app/api/send-email/route.ts
import { NextResponse } from 'next/server';
import { emailQueue } from '@/lib/queue';
export async function POST(req: Request) {
const { to, subject, body } = await req.json();
await emailQueue.add(
'send',
{ to, subject, body },
{
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
}
);
return NextResponse.json({ queued: true }, { status: 202 });
}
The attempts: 3 and backoff configuration tell BullMQ to retry failed jobs up to three times with exponential backoff between attempts. This handles transient failures (network timeouts, downstream service unavailability) automatically.
Run the worker as a separate process:
ts-node workers/email.worker.ts
In production, manage the worker process with PM2:
pm2 start workers/email.worker.ts --interpreter ts-node --name email-worker
Approach 3: Vercel Cron Jobs
For applications hosted on Vercel that need scheduled execution, Vercel Cron Jobs are the native solution. They call an API route on a schedule defined in vercel.json. The API route handles one execution and exits.
Configure the schedule in vercel.json:
{
"crons": [
{
"path": "/api/cron/sync",
"schedule": "0 * * * *"
},
{
"path": "/api/cron/cleanup",
"schedule": "0 2 * * *"
}
]
}
Create the API route:
// app/api/cron/sync/route.ts
import { NextResponse } from 'next/server';
export const maxDuration = 60;
export async function GET(req: Request) {
const authHeader = req.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorised' }, { status: 401 });
}
await syncExternalData();
return NextResponse.json({ completed: true });
}
The CRON_SECRET check verifies that the request is from Vercel rather than a random external caller. Set this environment variable in both the Vercel project settings and .env.local.
maxDuration extends the function timeout for this route. Vercel adds this to the function configuration on deployment.
Failure Handling
For node-cron jobs, wrap the task in a try/catch and log both success and failure. Without this, a thrown error in a scheduled task fails silently:
cron.schedule('0 * * * *', async () => {
try {
await task();
console.log('Task completed:', new Date().toISOString());
} catch (err) {
console.error('Task failed:', err);
// optionally: send an alert, increment a metric, write to a database
}
});
BullMQ handles retries automatically when attempts is configured. For tasks that must not be retried after a certain type of failure, the worker can inspect the error and decide:
const worker = new Worker('email', async (job) => {
try {
await sendEmail(job.data);
} catch (err) {
if (err instanceof PermanentFailureError) {
// mark as failed without retrying
await job.moveToFailed(err, job.token ?? '');
}
throw err; // rethrow to trigger retry
}
}, { connection });
Monitoring
The minimum useful monitoring for any background job system is:
Log the start time, completion time, and outcome of every job execution
Log errors with enough context to diagnose the failure
Alert when a job fails after exhausting retries
For BullMQ, Bull Board provides a web UI that shows queue status, job counts by state, and individual job details:
npm install @bull-board/express @bull-board/api
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { emailQueue } from './src/lib/queue';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(emailQueue)],
serverAdapter,
});
app.use('/admin/queues', serverAdapter.getRouter());
Protect this route with authentication before deploying.
Deployment Summary
Platform | Recommended approach |
|---|---|
VPS, Fly.io, Render (persistent) | node-cron via custom server, or BullMQ worker process |
Railway | BullMQ with Railway's Redis add-on, separate worker service |
Vercel | Vercel Cron Jobs for scheduled tasks; BullMQ for async queues requires a separate worker host |
Docker Compose | Separate worker container alongside the Next.js container |
Key Takeaways
Serverless platforms do not keep processes alive between requests. A cron job registered inside an API route will not run reliably on Vercel or similar platforms.
node-cron works correctly on persistent server deployments. Use a custom server entry point to initialize the scheduler alongside the Next.js server.
BullMQ with Redis is the correct choice for durable, reliable job queues. It handles retries, persistence, and concurrent workers.
Vercel Cron Jobs are the native solution for scheduled execution on Vercel. Protect the endpoint with a secret to prevent unauthorized invocations.
Always wrap scheduled tasks in try/catch. Silent failures in background jobs are difficult to detect and debug.
Bull Board provides a useful monitoring UI for BullMQ queues without requiring custom logging infrastructure.
Conclusion
Background jobs in Next.js require choosing the right approach for the hosting environment. node-cron for simple scheduled tasks on persistent servers, BullMQ for durable and scalable job queues, and Vercel Cron Jobs for serverless-native scheduled execution. Each trades off complexity for reliability in different ways.
The common thread is that reliable background processing requires explicit failure handling and observability. A job that silently fails is worse than a job that does not exist, because the system appears to be working when it is not.
Working on a specific background job scenario in Next.js and running into a problem? Describe it in the comments.




