Node.js cron has three patterns: node-cron (in-process, the schedule lives in memory), OS-level crontab (the schedule lives on the box), and BullMQ (Redis-backed queue with cron, the schedule is in Redis and survives restarts). The right answer is node-cron for dev and small projects, crontab for a single-VM production with one instance, BullMQ for a multi-instance production with a real schedule. The mistake every team makes: the team uses node-cron in a multi-instance production, every instance runs the job at the same time, the team’s data is corrupted.
Table of contents
- The node-cron — the in-process pattern
- The crontab — the OS-level pattern
- The BullMQ — the Redis-backed queue with cron
- The right pattern for each scenario
- The overlap problem — the right answer for the multi-instance case
- The timezone problem — when the cron runs at the wrong time
- The observability problem — when nobody knows if the job ran
- How this fits the rest of the stack
- FAQ
The node-cron — the in-process pattern
The node-cron package is the in-process scheduler. The team calls cron.schedule('*/5 * * * *', () => { ... }) at app startup, the schedule runs in the same process as the Express server. The pattern is small, it works in dev, the schedule is lost on every restart.
The right answer for node-cron is dev, demo, and small projects (a Raspberry Pi, a single-instance app). The wrong answer is node-cron for a multi-instance production — every instance runs the job at the same time, the team’s data is corrupted.
The crontab — the OS-level pattern
The OS-level crontab is the schedule on the box. The team runs crontab -e, adds a line that calls the Node script on the schedule, the OS scheduler fires the script. The pattern survives an app restart, the pattern is lost on container recreation.
The right answer for crontab is a single-VM production with one instance. The wrong answer is crontab for a multi-instance production — every VM runs the job at the same time, the team’s data is corrupted.
The BullMQ — the Redis-backed queue with cron
BullMQ is a Redis-backed queue for Node.js. The team creates a queue, the team schedules a job (queue.add('my-job', data, { repeat: { pattern: '*/5 * * * *' } })), BullMQ runs the job on the schedule. The schedule is in Redis, the schedule survives restarts, BullMQ handles the concurrency (one instance runs the job, the other instances wait).
The right answer for BullMQ is a multi-instance production with a real schedule. The wrong answer is BullMQ for a single-instance dev — the team is paying for the Redis dependency, the team is not using the multi-instance feature.
The right pattern for each scenario
The right answer is a function of the deploy model:
- Single-process dev — node-cron, no Redis needed.
- Single-VM production with one instance — OS-level crontab.
- Multi-instance production, no Redis —
node-cron+ external lock (a database lock, a Redis lock). - Multi-instance production with Redis — BullMQ.
- Serverless function on a schedule — the platform’s scheduled function trigger (Vercel Cron, AWS EventBridge).
The overlap problem — the right answer for the multi-instance case
The overlap problem: the team has 3 instances of the app, every instance runs the cron job at the same time, the team’s data is corrupted. The right answer is to have only one instance run the job, the wrong answer is to let every instance run the job.
The standard fix: a distributed lock. The right answer is BullMQ (the queue handles the lock), the right answer for a homegrown system is a Redis lock (SET lock:my-job <uuid> EX 60 NX), the right answer for a database-backed system is a row-level lock (SELECT ... FOR UPDATE SKIP LOCKED).
The timezone problem — when the cron runs at the wrong time
The timezone problem: the team’s server is in UTC, the team’s users are in EST, the cron runs at 14:00 UTC (10:00 EST) instead of 14:00 EST. The right answer is to set the timezone explicitly (process.env.TZ = 'America/New_York' at app startup, or cron.schedule('0 14 * * *', fn, { timezone: 'America/New_York' }) in node-cron). The wrong answer is to assume the server’s timezone matches the user’s — the cron runs at the wrong time.
The observability problem — when nobody knows if the job ran
The observability problem: the cron job runs every 5 minutes, the team does not know if it succeeded, the team does not know if it failed. The right answer is to log every run (the start time, the end time, the success/failure), the right answer for production is to have the platform’s cron UI show the run history.
The right answer for BullMQ is the Bull Board UI (a web UI for the queue). The right answer for node-cron is to log to the platform’s log aggregator. The wrong answer is to assume the job ran — the team’s silent failure is a production incident.
How this fits the rest of the stack
The infrastructure question is a small piece of a larger pattern: the team’s runtime, storage, database, secret store, logs, and deployment platform are all parts of the same platform. The right answer is to model the full stack before the project ships, not after. The RunxBuild hosting calculator is the right place to do that exercise — pick the runtime, the memory tier, the storage, the secret store, and the egress, and the calculator shows what the deploy actually costs at the team’s actual usage.
Useful related references:
FAQ
How do I run a cron job in Node.js?
Three patterns: node-cron (in-process), OS-level crontab (the schedule on the box), BullMQ (Redis-backed queue). The right answer depends on the deploy model.
Should I use node-cron in production?
For a single-instance production, yes. For a multi-instance production, no — every instance runs the job, the team’s data is corrupted.
What is BullMQ?
A Redis-backed queue for Node.js. The right answer is BullMQ for a multi-instance production with a real schedule.
How do I prevent overlapping cron runs in Node.js?
Use a distributed lock: BullMQ (the queue handles the lock), a Redis lock (SET lock:my-job <uuid> EX 60 NX), or a database lock (SELECT ... FOR UPDATE SKIP LOCKED).
How do I set the timezone in node-cron?
cron.schedule('0 14 * * *', fn, { timezone: 'America/New_York' }). The right answer is to always set the timezone explicitly, never assume the server’s timezone.
How do I see the run history of a cron job?
For BullMQ: the Bull Board UI. For node-cron: log to the platform’s log aggregator. The right answer is the platform’s run history, not the team’s grep.
Can I run a cron job in a serverless function?
Not node-cron (the function is not always running). The right answer is the platform’s scheduled function trigger (Vercel Cron, AWS EventBridge).
What is the right cron pattern for a Docker container?
OS-level crontab (baked into the image), BullMQ (Redis-backed, survives container recreation), or the platform’s cron (managed). The wrong answer is node-cron in a multi-instance container deployment.