Back to Blog
Node.js13 min read

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:

Featurenode-cronAgenda.jsBullMQ
StorageIn-memoryMongoDBRedis
Survives restartNoYesYes
Cron expressionsYes (+ seconds)Yes + human-readableYes + fixed intervals
RetriesManualBuilt-inBuilt-in + backoff
Concurrency controlNoYesYes + rate limiting
Multi-process safeNoYes (MongoDB locks)Yes (Redis locks)
DashboardNoAgendashBull Board
Timezone supportYesLimitedYes
Setup complexityMinimalModerateModerate
Best forSimple scripts, devMongoDB appsHigh-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 UTC

BullMQ

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

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.