Node.js Cron Libraries: node-cron vs node-schedule vs Bree
by Sinthuyan Arulselvam · June 13, 2026
Node.js ships with no built-in scheduler. There is no cron module in the standard library, no setInterval replacement that understands cron syntax, and no daemon management layer. If you need a task to run every Tuesday at 3 AM, you are on your own. This gap has spawned a dense ecosystem of scheduling libraries, each taking a different philosophical position on what "scheduling" even means. Three dominate real-world usage: node-cron, node-schedule, and Bree. Understanding where each begins and ends saves hours of debugging subtle timezone bugs or missed jobs in production.
Why So Many Libraries
The absence of a scheduler in the Node.js standard library is intentional. Node's core team focuses on low-level primitives — event loop, streams, networking — and leaves higher-level concerns to npm. The result is that every team building a scheduled job reaches for a community package, and the community has built several, each solving a slightly different shape of the problem.
node-cron fills the simplest slot: give it a cron expression, give it a function, and it calls that function on schedule. It is the "just works" option, suitable for a single process with a handful of tasks. node-schedule pushes further into scheduling flexibility — it supports cron syntax, but also JavaScript Date objects, recurrence rules, and timezone-aware scheduling out of the box. Bree is in a different category entirely: it treats scheduling as a job queue concern, running each task in an isolated worker thread or child process, with first-class support for graceful shutdown, retries, and concurrency control.
The right choice depends on the shape of your workload. A simple webhook pinger every five minutes needs node-cron. A billing system that must not double-run needs Bree. A reporting pipeline with complex recurrence patterns needs node-schedule. The libraries do overlap, which is why the decision is not always obvious.
node-cron
API and Basic Usage
node-cron exposes a minimal API. The primary function is cron.schedule, which accepts a cron expression string and a callback. The expression follows the standard five-field format — minute, hour, day-of-month, month, day-of-week — with no support for seconds by default in all versions, though some forks and versions add a sixth field.
const cron = require("node-cron");
// Runs every day at 08:30
cron.schedule("30 8 * * *", () => {
console.log("Morning report triggered");
});
// Runs every 15 minutes
cron.schedule("*/15 * * * *", () => {
sendHeartbeat();
}); The return value is a ScheduledTask object with start(), stop(), and destroy() methods. Tasks start automatically unless you pass { scheduled: false } as an option. This is useful when you want to register a task without immediately activating it.
const task = cron.schedule("0 2 * * *", runDatabaseBackup, {
scheduled: false,
timezone: "America/New_York"
});
// Start it conditionally
if (process.env.NODE_ENV === "production") {
task.start();
} Timezone Support
node-cron added timezone support via the timezone option in later versions. Under the hood it uses the luxon or native Intl API depending on the version. The timezone string must be a valid IANA timezone identifier. Without this option, the schedule fires relative to the server's local time — a common source of bugs when deploying to UTC-based cloud infrastructure.
Limitations
node-cron runs callbacks in the same process thread. If your callback blocks the event loop — a slow database query, a CPU-heavy computation — every other scheduled task waits. There is no built-in concurrency protection: if a job takes longer than its interval, the next invocation fires anyway, and both run simultaneously. There is also no job queue, no retry mechanism, and no graceful shutdown hook. For simple fire-and-forget tasks this is fine. For anything production-critical, these absences become real risks.
node-schedule
RecurrenceRule Objects
node-schedule's distinguishing feature is its RecurrenceRule object. Instead of parsing a cron string, you construct a rule using explicit JavaScript properties. This is more verbose but more readable, and it avoids the mental overhead of counting cron fields.
const schedule = require("node-schedule");
const rule = new schedule.RecurrenceRule();
rule.dayOfWeek = [1, 2, 3, 4, 5]; // Monday through Friday
rule.hour = 9;
rule.minute = 0;
rule.tz = "Europe/London";
const job = schedule.scheduleJob(rule, () => {
console.log("Weekday morning job fired");
}); You can also pass a plain object literal instead of instantiating RecurrenceRule explicitly:
const job = schedule.scheduleJob(
{ hour: 14, minute: 30, tz: "Asia/Tokyo" },
() => {
generateAfternoonReport();
}
); Date-Based Scheduling
node-schedule accepts a JavaScript Date object as the first argument. The job fires once at that exact moment, then is automatically cancelled. This is a pattern that node-cron simply cannot handle — there is no way to express "run once at this specific Unix timestamp" in a cron expression.
const runAt = new Date(2025, 11, 31, 23, 59, 0); // New Year's Eve
const oneTimeJob = schedule.scheduleJob(runAt, () => {
sendNewYearNotification();
}); Timezone Support and Footprint
The tz property on a rule or object literal sets the timezone for evaluation. node-schedule internally uses the node-cron-style parsing combined with timezone lookups. It has a heavier dependency footprint than node-cron — pulling in its own date parsing and recurrence logic. For most applications this is not a meaningful concern, but it is worth noting if you are building a Lambda-style function with strict cold-start budgets.
Like node-cron, node-schedule runs all callbacks in-process. Job cancellation is possible by calling job.cancel(), which prevents future invocations but does not interrupt a currently running callback.
const job = schedule.scheduleJob("0 */6 * * *", performSync);
// Cancel after first run if a condition is met
function performSync() {
doWork().then(() => {
if (syncComplete) {
job.cancel();
}
});
} Bree
Worker Threads and Process Isolation
Bree is architecturally different from the other two. Every job runs in a separate worker thread (via worker_threads) or child process. This means a crashing job cannot take down the main process, a blocking job cannot starve other jobs, and each job has its own isolated memory space. For production workloads where reliability matters, this isolation is the single most important architectural advantage Bree offers.
const Bree = require("bree");
const bree = new Bree({
jobs: [
{
name: "send-reminders",
interval: "5m",
path: "./jobs/send-reminders.js"
},
{
name: "nightly-report",
cron: "0 2 * * *",
timeout: "1m",
cronValidate: {
override: {
useSeconds: false
}
}
}
]
});
await bree.start(); Each job is a separate file. Bree loads it as a worker script. The job file has no special API requirements — it is just a Node.js module that runs to completion. When the script exits, Bree marks the job as done.
// jobs/send-reminders.js
const { parentPort } = require("worker_threads");
async function run() {
const reminders = await fetchDueReminders();
await Promise.all(reminders.map(sendEmail));
parentPort.postMessage("done");
}
run().catch((err) => {
console.error("Job failed:", err);
process.exit(1);
}); Graceful Shutdown
Bree provides built-in graceful shutdown. Calling bree.stop() signals all running workers to finish, waits for them to exit cleanly, then tears down. This is critical in Kubernetes or Heroku environments where pods receive a SIGTERM before being killed. Without graceful shutdown, in-flight jobs get truncated mid-execution.
process.on("SIGTERM", async () => {
await bree.stop();
process.exit(0);
}); Job Queues and Retries
Bree supports closeWorkerAfterMs to kill hung workers, timeout to set maximum execution time, and a workerData option to pass typed data into the worker at startup. It does not have a persistent job queue — if the process restarts, queued jobs are lost — so for durable job queues you still need BullMQ or similar. But for in-process reliability under load, Bree is the strongest option of the three.
Timezone Handling Compared
Timezone bugs in schedulers are insidious. The job appears to work in development (your local timezone), then fires at the wrong time in production (UTC on most cloud hosts). All three libraries handle timezones, but they differ in defaults and internals.
node-cron defaults to the process timezone — whatever process.env.TZ or the OS locale reports. You must explicitly pass timezone: "America/Chicago" to get consistent behavior. If you forget, the job fires in server local time, which is almost always UTC in production. The fix is a single option, but the failure mode is silent.
node-schedule requires tz on the rule object. Without it, it also falls back to local time. Because rule objects are explicit and verbose, developers tend to notice the missing tz field more readily than when writing a cron string.
Bree passes cron expressions to the cron-validate and croner packages internally. Timezone support is available via the timezone property on a job definition. Bree's worker-thread architecture does not automatically inherit the parent process's TZ environment variable, so you may need to pass it explicitly via workerData or set it in the worker file itself.
The universal recommendation: always set timezone explicitly, even when you believe the server runs in the right zone. Infrastructure changes — containerization, region failover, Daylight Saving Time — will eventually prove you wrong.
Overlap and Concurrency
A job that takes three minutes on a one-minute schedule will eventually run three concurrent instances. None of the three libraries prevent this by default.
node-cron fires the callback unconditionally on each tick. If the previous run is still executing, a new invocation starts in parallel. The fix is a manual guard flag:
let isRunning = false;
cron.schedule("* * * * *", async () => {
if (isRunning) return;
isRunning = true;
try {
await heavyTask();
} finally {
isRunning = false;
}
}); node-schedule behaves identically — no overlap protection. The same manual lock pattern applies.
Bree has better primitives here. You can set closeWorkerAfterMs to hard-kill a worker that exceeds a time budget. You can also check worker status before spawning a new one. But by default, if a cron interval fires while the previous worker is still running, Bree will spawn a second worker. Use hasSeconds and monitor worker state via Bree's emitted events to build overlap prevention at the orchestration level.
For true distributed overlap prevention — across multiple Node.js processes or servers — you need an external lock. Redis with SET NX PX (SET if Not eXists, with expiry) is the standard pattern. Acquire the lock at job start, release it on finish or timeout. This is library-agnostic and works regardless of which scheduler you choose.
Decision Matrix
| Feature | node-cron | node-schedule | Bree |
|---|---|---|---|
| Cron syntax support | 5-field | 5-field + RecurrenceRule | 5-field + 6-field (seconds) |
| One-time Date scheduling | No | Yes | Via timeout option |
| Timezone support | Yes (via option) | Yes (via tz property) | Yes (via job config) |
| Process isolation | No (in-process) | No (in-process) | Yes (worker threads) |
| Graceful shutdown | Manual | Manual | Built-in |
| Overlap protection | Manual guard | Manual guard | Partial (timeout kill) |
| Retry on failure | No | No | Limited (re-run config) |
| Bundle size | Small | Medium | Large |
| API complexity | Low | Medium | High |
| Best for | Simple tasks, scripts | Complex recurrence patterns | Production job pipelines |
| Production readiness | Low-medium | Medium | High |
When to Choose node-cron
Use node-cron when the task is low-stakes, the schedule is simple, and the overhead of the other libraries is not justified. Internal tooling, development utilities, and lightweight background tasks — cache warming, log rotation, simple health pings — are good fits. If the task fails silently or runs twice, nothing catastrophic happens.
When to Choose node-schedule
Use node-schedule when the schedule is complex and cron syntax becomes a liability. If you need to schedule jobs on specific weekdays, exclude certain months, or fire once at a computed future timestamp, the RecurrenceRule API is more maintainable than an equivalent cron string. It is also the right pick when your team is more comfortable reading JavaScript objects than cron field notation.
When to Choose Bree
Use Bree when jobs must not crash the main process, when execution time is unpredictable, or when you need clean observability into job lifecycle (start, progress, complete, fail). Billing runs, data pipeline steps, report generation, and any job that touches external APIs where timeouts are realistic — these all benefit from Bree's isolation model. The tradeoff is complexity: each job is a separate file, and the configuration API is verbose. That verbosity is the price of reliability.
Practical Recommendations for Production
Regardless of which library you use, treat scheduled jobs as first-class production concerns. Instrument every job with start and end logging, including execution duration. Set up alerting on jobs that exceed their expected runtime — a job that should take 30 seconds but is still running after 5 minutes has almost certainly stalled. Use structured logging so you can correlate job runs with application events in your observability stack.
For timezone correctness, set TZ=UTC in your production environment and express all cron schedules in UTC. This eliminates an entire class of Daylight Saving Time bugs. Document the UTC equivalent of any human-facing schedule ("runs at 09:00 UTC, which is 10:00 BST / 04:00 ET") so on-call engineers can reason about it without timezone arithmetic at 2 AM.
If you are running multiple application instances — load-balanced API servers, Kubernetes replicas — do not run the same scheduled job on every instance. You will get N concurrent executions of every task, one per replica. Use a dedicated worker process, a sidecar container, or an external scheduler like Cloudflare Cron Triggers, AWS EventBridge, or a database-backed queue to ensure exactly-once dispatch regardless of how many application replicas are running.
The Node.js scheduling ecosystem has matured significantly. The gap left by the standard library is filled, just not by a single obvious winner. Match the library to the workload, add operational discipline around monitoring and timezone handling, and you will avoid the majority of production scheduling incidents that teams experience in the wild.