Node.js Cron Jobs: Complete Guide (node-cron, Agenda, Bull)
Node.js applications frequently need to run tasks on a schedule: sending emails, syncing data, generating reports, cleaning up old records. This guide covers the three most popular approaches, from lightweight in-memory scheduling to distributed job queues, with production-ready code you can adapt for your own projects.
Overview of Node.js Scheduling Libraries
There are three main categories of scheduling solutions in the Node.js ecosystem. Each serves a different use case, and understanding the trade-offs will help you choose the right tool for your project.
In-Memory Schedulers (node-cron, node-schedule)
Schedule functions to run at specific times using cron expressions. Jobs live in memory and are lost when the process restarts. Best for simple tasks where missed runs are acceptable.
Persistent Job Queues (Agenda.js)
Store job definitions and schedules in MongoDB. Jobs survive process restarts and can be managed through a web UI. Best for applications that already use MongoDB.
Distributed Queues (BullMQ, Bee-Queue)
Redis-backed queues with advanced features like priorities, rate limiting, and horizontal scaling. Best for high-throughput applications that need guaranteed processing.
node-cron: Simple In-Memory Scheduling
node-cron is the most popular scheduling library for Node.js, with over 3 million weekly downloads. It uses the standard cron expression syntax and runs tasks as in-memory timers.
npm install node-cron
Basic Usage
import cron from 'node-cron';
// Run every 15 minutes
cron.schedule('*/15 * * * *', () => {
console.log('Syncing data from external API...');
syncExternalData();
});
// Run every day at 2:30 AM
cron.schedule('30 2 * * *', async () => {
console.log('Running nightly cleanup...');
try {
const deleted = await cleanupExpiredSessions();
console.log(`Cleaned up ${deleted} expired sessions`);
} catch (err) {
console.error('Cleanup failed:', err);
}
});
// Run weekdays at 9 AM, with timezone
cron.schedule('0 9 * * 1-5', () => {
sendDailyDigestEmail();
}, {
timezone: 'America/New_York'
});Validating and Managing Tasks
import cron from 'node-cron';
// Validate a cron expression before scheduling
const isValid = cron.validate('*/15 * * * *');
console.log(isValid); // true
// Store a reference to start/stop tasks dynamically
const task = cron.schedule('0 * * * *', () => {
fetchLatestPrices();
}, {
scheduled: false // Don't start immediately
});
// Start when ready
task.start();
// Stop during maintenance
task.stop();Extended Syntax (6 fields with seconds)
node-cron supports an optional sixth field for seconds, placed before the minute field:
// Run every 30 seconds
cron.schedule('*/30 * * * * *', () => {
checkHealthEndpoint();
});Limitation
node-cron is in-memory only. If your Node.js process crashes or restarts, all scheduled tasks stop and any currently running task is killed mid-execution. There is no persistence, no retry logic, and no way to see what ran or failed. For production workloads that cannot tolerate missed runs, use Agenda.js, BullMQ, or an external scheduler.
Agenda.js: MongoDB-Backed Job Queue
Agenda.js stores job definitions and schedules in MongoDB. Jobs persist across restarts, support retries, and can be managed at runtime. If your application already uses MongoDB, Agenda adds scheduled jobs without introducing another dependency.
npm install agenda
Setup and Job Definitions
import { Agenda } from 'agenda';
const agenda = new Agenda({
db: {
address: process.env.MONGODB_URI || 'mongodb://localhost:27017/myapp',
collection: 'scheduledJobs',
},
processEvery: '30 seconds', // How often Agenda polls for due jobs
maxConcurrency: 5, // Max concurrent jobs across all types
defaultConcurrency: 1, // Max concurrent jobs per type
});
// Define job types
agenda.define('send-daily-digest', async (job) => {
const { userSegment } = job.attrs.data;
const users = await User.find({ segment: userSegment, digestEnabled: true });
for (const user of users) {
await sendDigestEmail(user);
}
console.log(`Sent digest to ${users.length} users in segment: ${userSegment}`);
});
agenda.define('cleanup-expired-tokens', async () => {
const result = await Token.deleteMany({
expiresAt: { $lt: new Date() }
});
console.log(`Deleted ${result.deletedCount} expired tokens`);
});
agenda.define('sync-inventory', async (job) => {
const { warehouseId } = job.attrs.data;
await syncWarehouseInventory(warehouseId);
}, {
lockLifetime: 10 * 60 * 1000, // 10 minute lock (for long-running jobs)
priority: 'high',
});Scheduling Jobs
// Start Agenda
await agenda.start();
// Schedule recurring jobs
await agenda.every('0 8 * * *', 'send-daily-digest', {
userSegment: 'premium'
});
await agenda.every('*/30 * * * *', 'cleanup-expired-tokens');
// Schedule with human-readable intervals
await agenda.every('2 hours', 'sync-inventory', {
warehouseId: 'warehouse-east'
});
// Schedule a one-time job
await agenda.schedule('in 5 minutes', 'send-daily-digest', {
userSegment: 'trial-expiring'
});
// Cancel a scheduled job
await agenda.cancel({ name: 'sync-inventory' });Graceful Shutdown
async function gracefulShutdown() {
await agenda.stop();
console.log('Agenda stopped. No more jobs will be processed.');
process.exit(0);
}
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);BullMQ: Redis-Backed Distributed Queue
BullMQ is the successor to Bull and the most feature-complete job queue for Node.js. It uses Redis for storage and supports repeatable (cron) jobs, priorities, rate limiting, concurrency control, and horizontal scaling across multiple workers.
npm install bullmq
Creating Repeatable Jobs
import { Queue, Worker } from 'bullmq';
const connection = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
};
// Create a queue
const scheduledQueue = new Queue('scheduled-tasks', { connection });
// Add repeatable jobs using cron expressions
await scheduledQueue.upsertJobScheduler(
'nightly-backup',
{
pattern: '0 2 * * *', // Every day at 2 AM
tz: 'UTC',
},
{
name: 'nightly-backup',
data: { type: 'full', compress: true },
}
);
await scheduledQueue.upsertJobScheduler(
'sync-data',
{
pattern: '*/15 * * * *', // Every 15 minutes
},
{
name: 'sync-data',
data: { source: 'external-api' },
}
);
// Fixed interval alternative (every 5 minutes)
await scheduledQueue.upsertJobScheduler(
'health-check',
{
every: 300000, // 5 minutes in milliseconds
},
{
name: 'health-check',
data: { endpoints: ['/api/health', '/api/ready'] },
}
);Processing Jobs with Workers
const worker = new Worker('scheduled-tasks', async (job) => {
switch (job.name) {
case 'nightly-backup':
console.log(`Starting ${job.data.type} backup...`);
const result = await performBackup(job.data);
return { backupId: result.id, size: result.sizeBytes };
case 'sync-data':
const synced = await syncFromApi(job.data.source);
return { recordsSynced: synced };
case 'health-check':
for (const endpoint of job.data.endpoints) {
const res = await fetch(`http://localhost:3000${endpoint}`);
if (!res.ok) throw new Error(`${endpoint} returned ${res.status}`);
}
return { healthy: true };
default:
throw new Error(`Unknown job type: ${job.name}`);
}
}, {
connection,
concurrency: 3, // Process up to 3 jobs simultaneously
});
// Event handlers
worker.on('completed', (job, result) => {
console.log(`Job ${job.name} completed:`, result);
});
worker.on('failed', (job, err) => {
console.error(`Job ${job?.name} failed:`, err.message);
// Send alert to Slack, PagerDuty, etc.
});Monitoring with Bull Board
BullMQ integrates with Bull Board, an open-source dashboard that shows job status, history, and lets you retry or remove jobs through a web UI:
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(scheduledQueue)],
serverAdapter,
});
// Mount on your Express app
app.use('/admin/queues', serverAdapter.getRouter());Library Comparison Table
Here is a side-by-side comparison to help you decide which library fits your needs:
| Feature | node-cron | Agenda.js | BullMQ |
|---|---|---|---|
| Storage | In-memory | MongoDB | Redis |
| Survives restart | No | Yes | Yes |
| Cron expressions | Yes (+ seconds) | Yes + human-readable | Yes + fixed intervals |
| Retries | Manual | Built-in | Built-in + backoff |
| Concurrency control | No | Yes | Yes + rate limiting |
| Multi-process safe | No | Yes (MongoDB locks) | Yes (Redis locks) |
| Dashboard | No | Agendash | Bull Board |
| Timezone support | Yes | Limited | Yes |
| Setup complexity | Minimal | Moderate | Moderate |
| Best for | Simple scripts, dev | MongoDB apps | High-scale production |
Timezone Handling
Timezone bugs are one of the most common causes of cron jobs running at the wrong time. Here is how each library handles timezones, and the patterns you should follow:
node-cron
// Specify timezone per task
cron.schedule('0 9 * * *', sendReport, {
timezone: 'Europe/Berlin'
});
// Without timezone, node-cron uses the system timezone
// In Docker/cloud, this is usually UTCBullMQ
await queue.upsertJobScheduler('morning-report', {
pattern: '0 9 * * 1-5',
tz: 'America/New_York', // IANA timezone identifier
});Best practice: always specify the timezone explicitly
Never rely on the system timezone. Your code may run on a local machine (local TZ), a cloud server (usually UTC), or a Docker container (UTC by default). Explicit timezones prevent surprises. Use IANA identifiers like America/New_York, never UTC offsets like UTC-5 (which don't account for daylight saving).
Building cron expressions visually?
Our free Cron Expression Generator lets you build and test schedules before coding them into your Node.js app. Preview the next 5 execution times in any timezone.
Error Handling Patterns
Unhandled errors in scheduled jobs can crash your Node.js process. Here is a robust pattern for each library:
Wrapper for node-cron
function safeSchedule(
expression: string,
taskFn: () => Promise<void>,
options?: cron.ScheduleOptions
) {
return cron.schedule(expression, async () => {
const startTime = Date.now();
try {
await taskFn();
const duration = Date.now() - startTime;
console.log(`[CRON] Task completed in ${duration}ms`);
} catch (err) {
const duration = Date.now() - startTime;
console.error(`[CRON] Task failed after ${duration}ms:`, err);
// Send to your error tracking service
Sentry.captureException(err, {
tags: { type: 'cron-job', expression },
});
}
}, options);
}
// Usage
safeSchedule('*/15 * * * *', async () => {
await syncExternalData();
});BullMQ with Retry Backoff
// Configure retry strategy when adding jobs
await queue.upsertJobScheduler('critical-sync', {
pattern: '*/5 * * * *',
}, {
name: 'critical-sync',
opts: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000, // 5s, 10s, 20s
},
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
}
});Scaling Scheduled Jobs
When you run multiple instances of your Node.js application (behind a load balancer, in a Kubernetes deployment, or with PM2 cluster mode), in-memory schedulers like node-cron will execute jobs on every instance. A job scheduled for 0 9 * * * across 4 instances means it runs 4 times at 9 AM.
Solutions by library
node-cron
Run the scheduler in a single, dedicated process. Use an environment variable like IS_SCHEDULER=true to conditionally enable cron jobs on only one instance.
Agenda.js
Handles this automatically. MongoDB document-level locks ensure only one instance picks up each job. All instances can run Agenda safely.
BullMQ
Handles this automatically. Redis-based locks ensure each job is processed exactly once. You can run workers on multiple machines for load distribution.
When to Use an HTTP-Based Scheduler
All three libraries share a common limitation: they run inside your Node.js process. If your server goes down, your cron jobs stop. This is where external HTTP-based schedulers provide a fundamentally different architecture.
An external scheduler like CronJobPro lives outside your application. It calls your HTTP endpoints on schedule, which means:
- No library to install or maintain. Your Node.js code exposes a route like
/api/cron/daily-cleanup. CronJobPro calls it. That's it. - Works on serverless. Vercel, Netlify, Cloudflare Workers, and AWS Lambda have no persistent process to run cron in. An external scheduler triggers your serverless functions on schedule.
- Built-in monitoring and alerts. Every execution is logged with status code, response time, and response body. Failed jobs trigger email or webhook notifications automatically.
- No scaling complications. The scheduler sends one HTTP request regardless of how many Node.js instances you run. No duplicate execution problem.
The typical pattern: expose your task as an API route, protect it with a secret header, and point CronJobPro at it:
// app/api/cron/daily-cleanup/route.ts (Next.js App Router)
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
// Verify the request is from CronJobPro
const authHeader = request.headers.get('Authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const deleted = await cleanupExpiredSessions();
return NextResponse.json({
success: true,
deletedSessions: deleted,
});
} catch (err) {
return NextResponse.json(
{ success: false, error: String(err) },
{ status: 500 }
);
}
}Then in CronJobPro, create a job that POSTs to https://yourapp.com/api/cron/daily-cleanup with the cron expression 0 0 * * * and the Authorization header.
Choosing the Right Approach
Use node-cron when...
You have a single-instance app, missed runs are tolerable, and you want the simplest possible setup. Good for development, scripts, and non-critical tasks.
Use Agenda.js when...
You already use MongoDB, need persistent schedules, and want a dashboard. Good for medium-scale applications with moderate scheduling needs.
Use BullMQ when...
You need high throughput, horizontal scaling, priorities, rate limiting, and advanced retry logic. The go-to choice for large-scale production systems.
Use an external scheduler (CronJobPro) when...
Your task is an HTTP endpoint, you want zero-infrastructure monitoring, you run on serverless, or you need a non-technical team to manage schedules.
Related Articles
Run scheduled tasks in Docker with cron, Supercronic, and docker-compose.
Cron Job Monitoring Best PracticesHeartbeat checks, log aggregation, alerting, and dashboards for cron jobs.
Vercel Cron Jobs GuideSchedule serverless functions on Vercel with cron expressions.
Python Cron Jobs GuideSchedule tasks in Python with APScheduler, Celery Beat, and crontab.
Schedule your Node.js endpoints externally
CronJobPro calls your API routes on schedule with automatic retries, monitoring, and alerts. No npm packages to install. Free for up to 5 jobs.