Node.js scheduling breaks in a way that's uniquely frustrating: the process exits. That's it. You write a script, test it, add a setInterval, and it works perfectly in your terminal. Deploy it, and nothing runs because the event loop drained and the process quit before the first tick. Or you put it in crontab, and cron can't find node because nvm installed it somewhere cron has never heard of.
This guide covers the practical options for running Node.js cron jobs on a schedule: system crontab, in-process libraries like node-cron and Croner, persistent job queues with BullMQ and Agenda, worker-thread isolation with Bree, and container-native approaches. Each solves a different problem. Pick the wrong one and you'll either over-engineer a five-line task or under-engineer something that needs retries and persistence.
System crontab: still the right first answer
For a Node.js script that runs, does work, and exits, crontab is hard to beat. No npm packages, no running process, no Redis. The OS handles scheduling and your script doesn't need to stay alive between runs. If you need a refresher on cron expression syntax, we have a full guide covering that.
A script that cleans up expired sessions every hour:
0 * * * * /usr/local/bin/node /opt/app/scripts/cleanup-sessions.js >> /var/log/cleanup.log 2>&1
Notice /usr/local/bin/node, not just node. That full path matters.
The nvm problem (and how to work around it)
Cron doesn't load your shell profile. No .bashrc, no .nvm/nvm.sh, no fnm shims. The PATH cron sees is typically just /usr/bin:/bin. If you installed Node through nvm, running which node in your terminal gives you something like /home/deploy/.nvm/versions/node/v20.18.0/bin/node. Cron doesn't know that path exists.
Two fixes. First option, use the absolute path directly:
0 * * * * /home/deploy/.nvm/versions/node/v20.18.0/bin/node /opt/app/scripts/cleanup.js >> /var/log/cleanup.log 2>&1
Second, write a wrapper script that sources nvm:
#!/bin/bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
cd /opt/app
node scripts/cleanup-sessions.js
Then point crontab at the wrapper: 0 * * * * /opt/app/scripts/run-cleanup.sh >> /var/log/cleanup.log 2>&1
The direct-path approach is simpler to debug because crontab -l shows everything. The wrapper is better when multiple scripts share the same Node version and environment setup.
Relative paths will betray you
Cron runs from / or the user's home directory. A require('./config') in your script resolves against that working directory, not the script's location. Use path.resolve(__dirname, 'config') or import.meta.dirname (Node 20.11+) instead. Wasted a solid hour on this one before realizing the script worked fine when I cd'd into the project first.
Quick debugging trick: add * * * * * env > /tmp/cron-env.txt to your crontab temporarily. It dumps cron's actual environment to a file, so you can see exactly which PATH, HOME, and SHELL values your jobs inherit.
node-cron: the most popular in-process scheduler
When your Node.js scheduled tasks live inside a running application (an Express API that regenerates a cache, a worker service that polls an external queue), you don't want a separate crontab entry. You want the scheduler embedded in the same process that has your database connections and configuration.
node-cron (v4.6.0, ~1.1M weekly downloads) is the standard pick for this. Zero dependencies since the TypeScript rewrite in v4. If you're new to cron job syntax, start with that primer.
import cron from 'node-cron';
cron.schedule('0 3 * * *', async () => {
const expired = await db.sessions.deleteExpired();
console.log(`Cleaned ${expired.count} sessions`);
}, {
timezone: 'UTC'
});
// Your Express/Fastify server keeps running — cron fires in the background
The timezone option matters more than it looks. Without it, node-cron uses the system timezone. Deploy the same container to a server in a different region and your "3 AM" job suddenly runs at 3 AM local time there. Always set timezone: 'UTC' unless you have a specific reason not to.
A memory leak that catches people in multi-tenant setups
If you dynamically create and destroy scheduled tasks (one per tenant, for example), the internal task Map grows unbounded. Old task references don't get garbage collected unless you explicitly call task.stop(). For anything that creates tasks at runtime, keep a Map of task references keyed by tenant ID and call .stop() on the old task before scheduling a replacement.
There's also a documented DST edge case: during fall-back transitions, node-cron can schedule the same hour twice. For most jobs this is harmless (your cache regenerates an extra time), but for billing or data export tasks, add your own idempotency check. Or use UTC and sidestep it entirely.
Croner: the modern alternative worth knowing about
Croner (v10.x) is what node-cron would look like if someone rebuilt it with modern JavaScript in mind. TypeScript-native, zero dependencies, works in Node, Deno, Bun, and browsers. The API is nearly identical to node-cron, but the timezone and DST handling is better because it uses the Intl API internally instead of manual offset math.
import { Cron } from 'croner';
const job = new Cron('0 */6 * * *', { timezone: 'Europe/Berlin' }, async () => {
await generateSitemapIndex();
console.log('Sitemap rebuilt');
});
// To stop later: job.stop()
Croner supports seconds (6-field expressions), last-day-of-month (L), weekday-nearest (W), and nth-weekday (#) syntax that node-cron doesn't. If your schedule needs "last Friday of every month at 5 PM," Croner handles it natively.
For new projects, I'd lean toward Croner over node-cron. The API surface is smaller, the edge cases are handled better, and it runs everywhere. For existing codebases already using node-cron, there's no urgent reason to migrate.
BullMQ: when you need a real job queue
node-cron and Croner schedule functions in memory. Process dies, schedules vanish. No retries, no backoff, no visibility into what ran and what failed. For a cache refresh that's fine. For a nightly Stripe reconciliation job that handles real money, you want something that survives restarts and retries on failure.
BullMQ (v6.3.x) is the standard answer. It uses Redis as a persistence layer and message broker.
import { Queue, Worker } from 'bullmq';
const connection = { host: '127.0.0.1', port: 6379 };
const queue = new Queue('billing', { connection });
// Register a repeating job
await queue.upsertJobScheduler('nightly-reconcile', {
pattern: '0 2 * * *' // 2 AM daily
}, {
name: 'stripe-reconcile',
data: { batchSize: 1000 }
});
// Worker picks up and processes jobs
const worker = new Worker('billing', async (job) => {
const result = await reconcileStripePayments(job.data.batchSize);
return { processed: result.count };
}, { connection });
That upsertJobScheduler call is new in BullMQ v6 (the v5 API used add with repeat options). Jobs persist in Redis, survive process restarts, and you get automatic retries with exponential backoff. There's a dashboard (Bull Board) for inspecting queues, and the paid Pro version adds rate limiting and groups.
The tradeoff: you need Redis running. For a single cron job on a VPS, spinning up Redis just for scheduling is overkill. BullMQ earns its weight when you're already running Redis, or when you need queuing semantics (priorities, concurrency control, dead letter queues) alongside scheduling.
Agenda: persistent scheduling backed by MongoDB
If your stack runs on MongoDB instead of Redis, Agenda (v6.2.x) fills the same niche as BullMQ. It stores job definitions and execution history in a MongoDB collection, supports retries and configurable concurrency, and lets you query jobs directly in the database. Note that v6 introduced a pluggable backend system, so the connection setup changed from earlier versions.
import { Agenda } from 'agenda';
const agenda = new Agenda({
db: { address: 'mongodb://localhost:27017/jobs' }
});
agenda.define('generate invoice', async (job) => {
const { customerId } = job.attrs.data;
await createInvoice(customerId);
});
await agenda.start();
await agenda.every('0 9 * * 1', 'generate invoice', { customerId: 'acme-corp' });
Agenda polls MongoDB by default (configurable interval), so there's a slight delay between scheduled time and execution. For most use cases, a few seconds of latency is irrelevant. Where Agenda shines is inspectability: you can query the jobs collection directly with mongosh to see what's pending, what failed, and what ran when. Debugging production scheduling issues becomes a database query instead of grepping log files.
Bree: worker-thread isolation for crash-prone jobs
Most schedulers run your job function in the main thread. If that function throws an uncaught exception or eats all available memory, it takes down the entire process, including every other scheduled job and your HTTP server.
Bree (v9.2.x) runs each job in its own worker thread. One job crashes, the rest keep running. Your main process stays alive.
import Bree from 'bree';
const bree = new Bree({
jobs: [
{
name: 'rebuild-search-index', // runs jobs/rebuild-search-index.js
cron: '0 4 * * *'
},
{
name: 'send-digest-emails',
cron: '0 9 * * *'
}
]
});
await bree.start();
Each job is a separate file in a jobs/ directory. You can't define inline functions like node-cron. Worker threads have their own memory space, so sharing state with the main process requires parentPort.postMessage. More setup than simpler libraries, but the isolation is genuine: a memory leak in one job stays contained.
Pick Bree when your jobs are CPU-intensive (image processing, PDF generation, large CSV parsing) or when you've had production incidents where a bad job brought down the whole app.
Running Node.js cron jobs in Docker containers
Standard cron inside a Docker container has the same problems regardless of language: environment variables get stripped, output goes to syslog instead of stdout, and docker stop hangs for 10 seconds because cron ignores SIGTERM. Our Python cron guide covers the Docker pitfalls in more detail if you want the full picture.
The clean approaches:
In-app scheduling is the simplest. Your Express or Fastify server already runs as a long-lived process inside the container. Add node-cron or Croner to it and the scheduled jobs share the same process. No extra containers, no signal handling issues.
// server.js — your main app entrypoint
import express from 'express';
import cron from 'node-cron';
const app = express();
// ... routes ...
cron.schedule('0 * * * *', () => {
cleanupExpiredSessions();
});
app.listen(3000);
Dedicated cron container with Supercronic works when you need the schedules decoupled from your app. Supercronic is a Go binary that reads standard crontab syntax, runs in the foreground, preserves environment variables, and sends output to stdout where docker logs picks it up.
FROM node:20-alpine
RUN apk add --no-cache curl \
&& curl -fsSL https://github.com/aptible/supercronic/releases/download/v0.2.48/supercronic-linux-amd64 \
-o /usr/local/bin/supercronic \
&& chmod +x /usr/local/bin/supercronic
COPY package*.json ./
RUN npm ci --production
COPY . /app/
COPY crontab /etc/crontab
CMD ["supercronic", "/etc/crontab"]
And the crontab file:
0 3 * * * cd /app && node scripts/backup.js
*/15 * * * * node /app/scripts/sync-inventory.js
On Kubernetes, use CronJob resources instead. They handle scheduling, retry policies, and concurrency control natively. No need for an in-app scheduler or Supercronic when the orchestrator already has a cron primitive built in.
Serverless: Lambda, Vercel Cron, and friends
For tasks under 15 minutes that don't need a running server, a serverless function on a schedule replaces both the server and the scheduler. AWS EventBridge Scheduler triggers a Lambda; Vercel's vercel.json hits an API route on a cron schedule.
Vercel cron is the fastest to set up:
{
"crons": [
{
"path": "/api/daily-digest",
"schedule": "0 9 * * *"
}
]
}
One catch with Vercel's free (Hobby) plan: the minimum interval is once per day. Pro plan unlocks more frequent schedules. The schedule is always evaluated in UTC, so "9 AM" in the config is 9 AM UTC, not your local timezone.
AWS Lambda + EventBridge gives more control but more setup. You define the schedule, wire up IAM roles, and handle cold starts (100ms-3s depending on your function's size). The cost for a daily job is effectively zero on the free tier.
Both approaches have hard execution time limits. Lambda caps at 15 minutes; Vercel limits depend on your plan and compute model (up to 300 seconds on Hobby with Fluid Compute, up to 800 on Pro). If your job needs longer, you need a real server, an ECS task, or a Step Function.
Monitoring Node.js cron jobs for silent failures
Whichever scheduling approach you pick, the failure mode is the same: the job stops running and nobody notices. A database backup that silently stopped three days ago doesn't trigger any errors because no code is running to throw them. The scheduler itself might be fine. The job might have crashed once and never retried.
A heartbeat monitor catches this. Your job pings a webhook URL after successful completion. If the ping doesn't arrive within the expected window, you get an alert. The simplest pattern with Node's built-in fetch (available since Node 18, stable since Node 21):
const PING_URL = 'https://monitoring.example.com/ping/YOUR-CHECK-ID';
async function runWithHeartbeat() {
try {
await doBackup();
await fetch(PING_URL);
} catch (err) {
console.error('Backup failed:', err);
await fetch(`${PING_URL}/fail`).catch(() => {});
throw err;
}
}
Or from crontab, chained with && so the ping fires only on success:
0 3 * * * /usr/local/bin/node /opt/app/backup.js && curl -fsS --retry 3 https://monitoring.example.com/ping/YOUR-CHECK-ID > /dev/null
WatchCron's dead man's switch monitoring works exactly this way. Set an expected schedule and grace period, point your script at the ping URL, and if the ping stops arriving, alerts fire to Slack, email, or whatever channels you've configured. Setup takes a couple of minutes regardless of which scheduling approach you're using.
Picking the right approach
A single script on a VPS that runs once a day: system crontab. Nothing to install, nothing to maintain. Run which node to get the full path and you're done.
Scheduling inside a running Express or Fastify app: node-cron or Croner. They share the process, use the app's config and database connections, and need zero infrastructure beyond the app itself. Croner handles edge cases (DST, extended cron syntax) better; node-cron has the larger ecosystem.
Jobs that must survive process restarts, with retries and backoff: BullMQ if you have Redis, Agenda if you have MongoDB. Don't introduce either just for scheduling. The infrastructure overhead only pays off when you actually need queuing semantics.
CPU-heavy jobs that might crash: Bree. Worker-thread isolation keeps one bad job from killing your process.
Docker without Kubernetes: in-app scheduler for simple cases, Supercronic for complex schedules. On Kubernetes: use CronJob resources.
Serverless, short tasks: Lambda + EventBridge or Vercel Cron. Zero infrastructure, but hard execution time limits.
Whatever you pick, add monitoring. The scheduling part is the easy problem. Knowing when it breaks, before your users tell you, is the hard one.