Node.js Schedulers Compared: cron, node-cron, node-schedule, croner, Agenda, BullMQ
Contents
cron, node-cron, node-schedule, and croner keep schedules in your process's memory: a restart loses them, and each instance of the app runs its own copy unless you add coordination. Agenda and BullMQ store schedules in a database or Redis, so they survive restarts and many workers can share one schedule. Pick an in-process library for single-instance apps where a missed run is acceptable, and a persistent one when runs must survive deploys or the app runs on several instances.
This guide covers cron 4.4.0, node-cron 4.6.0, node-schedule 2.1.2, croner 11.0.0, Agenda 6.2.6, and BullMQ 6.5.2, each the current release on the npm registry (checked October 30, 2026). For error handling, restarts, and overlaps in node-cron and node-schedule, see Node.js cron job monitoring; for BullMQ Job Schedulers in depth, see BullMQ repeatable jobs and Job Schedulers. The .NET equivalent of this comparison is Hangfire vs Quartz.NET vs BackgroundService.
Two Models: Timers in the Process, or Jobs in a Store#
The first four libraries are timers. You call a function with a cron pattern, the library sets a timer for the next match, and the job is a JavaScript object. node-schedule's README says it plainly: jobs "will only fire as long as your script is running, and the schedule will disappear when execution completes" (README at v2.1.2). croner "operates in-memory, with no need for a database" (README at 11.0.0), and node-cron "coordinates but does not persist state to a database" (README at v4.6.0).
Agenda and BullMQ are job systems. The schedule is a record in a store, workers poll or subscribe to it, and the process that runs a job need not be the one that created the schedule. Agenda 6 has pluggable backends for MongoDB, PostgreSQL, and Redis, each a separate package (README at [email protected]). A BullMQ Job Scheduler lives in Redis, the default backend, or in PostgreSQL with the optional PostgreSQL backend (PostgreSQL backend guide at v6.5.2), and produces each run as a job that waits in the delayed state until it is due (Job Schedulers docs).
Node.js Schedulers at a Glance#
| cron (kelektiv) | node-cron | node-schedule | croner | Agenda | BullMQ | |
|---|---|---|---|---|---|---|
| Latest release | 4.4.0, December 9, 2025 (npm) | 4.6.0, July 5, 2026 (npm) | 2.1.2, October 24, 2026 (npm) | 11.0.0, October 26, 2026 (npm) | 6.2.6, July 21, 2026 (npm) | 6.5.2, October 30, 2026 (npm) |
| Weekly downloads, Oct 23-29, 2026 | 10,026,652 (api) | 6,769,583 (api) | 4,753,915 (api) | 14,035,929 (api) | 228,689 (api) | 13,183,574 (api) |
| Seconds field | Optional sixth field at the front (README) | Optional sixth field at the front (README) | Optional sixth field at the front (README) | Optional second, plus optional seventh field for year (README) | Patterns parsed by cron-parser (nextRunAt.ts) | Optional sixth field at the front (repeat strategies) |
| Extensions | Names for months and weekdays; no L, W, or # listed (README) |
L, L-n, W, LW, #, 5L, ? (README) |
#; no L or W (README) |
L, W, #, ?, + for AND (README) |
Also human intervals such as '1 hour' (README) |
Also every in milliseconds (repeat strategies) |
| Time zone | timeZone or utcOffset; default local (README) |
timezone option (README) |
tz on a RecurrenceRule (README) |
timezone or utcOffset (README) |
timezone option on every() (index.ts) |
tz repeat option (repeat options) |
| Persistence | None documented; jobs are CronJob objects (README) |
None (README) | None (README) | None, in memory (README) | MongoDB, PostgreSQL, or Redis backend (README) | Redis by default, or PostgreSQL (PostgreSQL backend) |
| Late or missed runs | threshold: a late tick within it runs, beyond it is skipped (README) |
execution:missed for each slot the timer woke too late to run (plan-beat.ts) |
Schedule is gone when the script stops (README) | Not documented (README) | One stored job per every() name; next run computed from lastRunAt (index.ts, nextRunAt.ts) |
One delayed job per scheduler, so an overdue run happens once (Job Schedulers); the next run is computed from now (job-scheduler.ts) |
| Overlap protection | waitForCompletion skips ticks while one runs (README) |
noOverlap (README) |
None documented (README) | protect (README) |
Job locks with lockLifetime, per-job concurrency (README) |
None built in for a scheduler: the next run is created when the current one starts, so runs can overlap with several workers or concurrency above 1 (Job Schedulers); use a dedicated queue with queue.setGlobalConcurrency(1) (global concurrency) |
| Multiple instances | No coordination documented (README) | distributed: true, env-var flag or Redis coordinator (README) |
README points to Agenda or Bree for multi-node (README) | No coordination documented (README) | Shared store with locking (README) | Shared Redis or PostgreSQL; schedules live in the store, not in each process (PostgreSQL backend) |
Cron Dialects Differ More Than They Look#
All six accept a five-field crontab pattern, but the same string does not always mean the same thing.
Day-of-month and day-of-week. In standard cron, 0 9 1 * 1 runs on the 1st of the month and on every Monday: when both day fields are restricted, a match on either one is enough (crontab(5)). croner keeps that OR logic by default and offers +MON or { domAndDow: true } for AND (README at 11.0.0). node-cron 4.6.0 combines the two fields with AND: both must match (README at v4.6.0). A pattern moved from crontab to node-cron can run on far fewer days. Avoid restricting both fields when a schedule has to be portable.
Seconds. A six-field pattern puts seconds first in cron, node-cron, node-schedule, croner, and BullMQ. kelektiv's cron defaults a missing seconds field to 0 (README at v4.4.0), and croner's mode option can force 5-, 6-, or 7-part parsing instead of detecting it (README at 11.0.0).
Weekday numbers. node-cron borrows L, W, #, and ? from Quartz but keeps standard weekday numbering (1 is Monday), so Quartz expressions do not carry over unchanged (README at v4.6.0). croner switches to Quartz numbering only with alternativeWeekdays: true (README at 11.0.0). In kelektiv's cron 4.x, months are numbered 1-12; the README's migration section notes they were 0-11 before (README at v4.4.0).
For the fields themselves, see cron expression syntax.
Time Zones and Daylight Saving Time#
kelektiv's cron runs in local time unless you pass timeZone (README at v4.4.0). BullMQ passes its repeat options, including tz, to cron-parser (job-scheduler.ts), and cron-parser 5.10.2, the version bullmq 6.5.2 depends on, uses the process's local zone when no zone is given (CronDate.ts). Set tz as a repeat option; the BullMQ guide has more. Set the zone explicitly in every library.
The documented daylight saving behavior differs:
- node-cron: in a fall-back the repeated hour runs once, so a
*/15schedule can pause for up to the length of the shift. The README recommendstimezone: 'UTC'for an uninterrupted interval (README at v4.6.0). - croner: fixed-time jobs such as
0 30 2 * * *run once at the first occurrence of a repeated time and, for a time that doesn't exist, at the first valid time after the gap. Interval jobs run on both occurrences of a repeated time and skip times that don't exist (README at 11.0.0).
The general problem is covered in cron and daylight saving time. Running in UTC avoids it.
What Happens After a Restart or Downtime#
For the four in-process libraries, a run that falls due while the process is down never happens, and nothing records that it didn't. The late-run features cover a running process that woke up late: kelektiv's threshold decides whether a late tick still runs (README at v4.4.0), and node-cron emits execution:missed for each slot its timer woke more than missedExecutionTolerance (1000 ms by default) after (scheduled-task.ts, plan-beat.ts at v4.6.0).
Agenda's every() saves a single job record for the name (type = 'single' in index.ts) and computes the next run from lastRunAt with cron-parser (nextRunAt.ts). Because there is one record with one next run time, missed runs cannot pile up as separate jobs. BullMQ behaves similarly: the overdue job runs once, late, and the next run is calculated from the current time, for a pattern the next occurrence after now (job-scheduler.ts at v6.5.2) and for every on Redis the next slot after now (getJobSchedulerEveryNextMillis.lua). The BullMQ guide covers this in depth.
Neither replays every missed slot. If each slot matters, for example a report per day, make the job find and process the gap itself; see missed runs after downtime.
Overlap and Multiple Instances#
Three in-process libraries can skip a run while the previous one is still going: waitForCompletion in kelektiv's cron, noOverlap in node-cron, and protect in croner. node-schedule has none, so track a running flag yourself (example). These options work within one process only. Two instances of the app are two schedulers, and each runs every job; see preventing overlapping cron jobs for locks that span processes.
node-cron 4.6.0 is the only in-process library here with built-in coordination: distributed: true makes one instance run each firing, using an environment-variable flag by default or a Redis coordinator for high availability (README at v4.6.0). Agenda and BullMQ coordinate through their store by design. For the patterns behind all of this, see distributed cron.
How to Choose#
- One instance, missed runs tolerable, standard crontab patterns: kelektiv's
cronor croner. croner addsL,W,#, a year field, and documented DST rules, and keeps standard OR semantics for day fields. - One instance now, several later, without a database: node-cron 4, for
noOverlap,distributed, and lifecycle events. Remember its AND rule for day-of-month plus day-of-week. - Calendar-style rules more than cron strings: node-schedule's
RecurrenceRule, with anerrorlistener on every job (why). - Schedules must survive deploys and run on one of many workers: Agenda on MongoDB, PostgreSQL, or Redis, or BullMQ on Redis or PostgreSQL. BullMQ if you need a work queue too.
Monitoring Whichever You Pick#
None of these libraries can tell you about a run that never started: a stopped process, a worker that never came up, or a schedule removed from the store all fail without an error. An outside monitor that knows the schedule catches those.
CronRadar's cronradar npm package is unavailable, so Node.js jobs report over HTTP check-ins. Check-ins do not discover jobs automatically: each job reports with its own monitor key and its schedule. The Node.js monitoring guide has a monitored(monitorKey, schedule, task) wrapper that sends start, complete, and fail check-ins with HTTP Basic authentication (the API key as the username, empty password), a five-second timeout, and the key read from CRONRADAR_API_KEY in the environment. It wraps any job function, so it works the same way with each library:
const { Cron } = require('croner')
// monitored() is the wrapper from the Node.js monitoring guide
new Cron(
'0 3 * * *',
{ timezone: 'UTC', protect: true },
monitored('nightly-backup', '0 3 * * *', performBackup),
)const { CronJob } = require('cron')
CronJob.from({
cronTime: '0 3 * * *',
onTick: monitored('nightly-backup', '0 3 * * *', performBackup),
timeZone: 'UTC',
waitForCompletion: true,
start: true,
})For BullMQ, check in from the worker's processor, as shown in the BullMQ guide.
CronRadar reads schedules as standard five-field cron evaluated in UTC (schedule syntax). Run the job in UTC and send a five-field pattern: drop a leading seconds field (0 0 2 * * * becomes 0 2 * * *). Five fields cannot express L or W, so schedule such a job on every candidate date and let it decide whether to do the work.
A monitor is pending until its first check-in and healthy while runs arrive. A late run is warning inside its grace period and critical once grace expires; a failure check-in makes it critical straight away. Alerts go to email, Slack, webhook, Discord, or PagerDuty.
Frequently Asked Questions#
What is the difference between cron and node-cron on npm?#
They are separate projects. cron is maintained in the kelektiv/node-cron repository and exports CronJob; node-cron is maintained in the node-cron/node-cron repository and exports schedule(). Their dialects and options differ: for example, node-cron combines day-of-month and day-of-week with AND.
Which Node.js scheduler supports seconds?#
cron, node-cron, node-schedule, croner, and BullMQ document an optional seconds field at the front of a six-field pattern, and croner also accepts a seventh field for the year. Agenda passes cron patterns to cron-parser.
Which Node.js schedulers keep jobs across restarts?#
Agenda, which stores jobs in MongoDB, PostgreSQL, or Redis, and BullMQ, which stores Job Schedulers in Redis or PostgreSQL. cron, node-cron, node-schedule, and croner keep schedules in memory and lose them when the process stops.
Do Agenda and BullMQ run every missed job after downtime?#
No. Each keeps one pending run per schedule, so after downtime the overdue run happens once and the next is calculated from there. Make the job process the missed period itself if every slot matters.
Does CronRadar integrate with node-cron, croner, Agenda, or BullMQ?#
No. The Node.js SDK is unavailable, and CronRadar does not discover Node.js jobs. Each job reports through HTTP check-ins with its own monitor key and schedule.
Put this guide to work
Connect your Node.js job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The Node.js SDK is temporarily unavailable: the published package cannot load because it is missing a required file. Use manual HTTP reporting below. This setup does not discover jobs automatically.