TL;DR
node-cron is the simplest — just cron expressions, no setup, widely used. node-schedule is more flexible — supports cron syntax, Date objects, and complex recurrence rules (run every X hours from 9am to 5pm, stop on a specific date). Croner is the modern choice — TypeScript-native, handles edge cases (DST, leap years), better error handling, and actively maintained. For basic cron jobs, node-cron is fine. For production task scheduling where timezone handling and error recovery matter, use Croner.
Key Takeaways
- node-cron: ~3M weekly downloads — simple cron syntax, minimal API, TypeScript via @types
- node-schedule: ~1.5M weekly downloads — cron + date-based + recurrence rules
- croner: ~600K weekly downloads — TypeScript-native, DST-aware, error recovery, best for production
- All three run in-process — use BullMQ/Inngest for distributed job queues
- Timezone support: Croner > node-schedule ≈ node-cron (all support TZ)
- For production, always add error handling — cron failures often go silent
Download Trends
| Package | Weekly Downloads | TypeScript | Timezone | Error Handling |
|---|---|---|---|---|
node-cron | ~3M | ✅ @types | ✅ | ⚠️ Manual |
node-schedule | ~1.5M | ✅ @types | ✅ | ⚠️ Manual |
croner | ~600K | ✅ Native | ✅ Excellent | ✅ Built-in |
Cron Expression Reference
All three libraries use standard cron syntax:
┌─────────── second (optional, 0-59)
│ ┌─────── minute (0-59)
│ │ ┌───── hour (0-23)
│ │ │ ┌─── day of month (1-31)
│ │ │ │ ┌─ month (1-12)
│ │ │ │ │ ┌ day of week (0-6, 0=Sunday)
│ │ │ │ │ │
* * * * * *
Examples:
"* * * * *" → Every minute
"0 * * * *" → Every hour at :00
"0 9 * * 1-5" → Mon-Fri at 9:00 AM
"0 0 1 * *" → First day of every month
"*/5 * * * *" → Every 5 minutes
"0 9,17 * * 1-5" → 9 AM and 5 PM weekdays
"0 0 * * 0" → Every Sunday midnight
node-cron
node-cron — minimal cron job scheduler for Node.js:
Basic usage
import cron from "node-cron"
// Run every minute:
cron.schedule("* * * * *", () => {
console.log("Running every minute:", new Date().toISOString())
})
// Run at midnight every day:
cron.schedule("0 0 * * *", async () => {
await resetDailyCounters()
})
// Run at 9 AM on weekdays (Mon-Fri):
cron.schedule("0 9 * * 1-5", async () => {
await sendDailyDigestEmails()
})
// With second precision (non-standard but node-cron supports it):
cron.schedule("*/30 * * * * *", () => {
// Runs every 30 seconds
checkHealthStatus()
})
// With timezone:
cron.schedule(
"0 9 * * *",
async () => {
await sendMorningReport()
},
{
timezone: "America/New_York", // Runs at 9 AM Eastern
}
)
Start/stop control
// Create but don't start immediately:
const task = cron.schedule(
"0 * * * *",
async () => {
await refreshPackageData()
},
{ scheduled: false }
)
// Start/stop programmatically:
task.start()
// Stop during maintenance:
async function performMaintenance() {
task.stop()
await runMigrations()
task.start()
}
// Destroy — removes task completely:
task.destroy()
Error handling (node-cron doesn't catch errors automatically):
import cron from "node-cron"
import { logger } from "./logger"
// Always wrap in try/catch or catch unhandled rejections:
cron.schedule("0 * * * *", async () => {
try {
await refreshPackageData()
logger.info("Package data refreshed successfully")
} catch (err) {
logger.error({ err }, "Failed to refresh package data")
// Error is swallowed without this catch — you'll never know it failed!
await alertOps("Scheduled job failed: refreshPackageData")
}
})
node-schedule
node-schedule — supports cron syntax plus Date and RecurrenceRule:
Cron syntax
import schedule from "node-schedule"
// Standard cron:
schedule.scheduleJob("0 0 * * *", async function() {
await resetDailyStats()
})
Date-based scheduling
// Run at a specific date/time:
const launchDate = new Date("2026-04-01T09:00:00Z")
schedule.scheduleJob(launchDate, function() {
console.log("Launch day!")
})
// Run once after a delay:
const inFiveMinutes = new Date(Date.now() + 5 * 60 * 1000)
schedule.scheduleJob(inFiveMinutes, function() {
sendFollowUpEmail()
})
RecurrenceRule — complex schedules
import schedule from "node-schedule"
// RecurrenceRule for fine-grained control:
const rule = new schedule.RecurrenceRule()
rule.dayOfWeek = [1, 2, 3, 4, 5] // Monday-Friday
rule.hour = 9 // 9 AM
rule.minute = 0
rule.tz = "America/New_York"
schedule.scheduleJob(rule, async function() {
await sendDailyReport()
})
// Run every 30 minutes between 8 AM and 6 PM:
const halfHourly = new schedule.RecurrenceRule()
halfHourly.minute = [0, 30]
halfHourly.hour = new schedule.Range(8, 18) // 8 AM - 6 PM
schedule.scheduleJob(halfHourly, function() {
checkApiHealth()
})
Managing scheduled jobs
const job = schedule.scheduleJob("0 * * * *", async function() {
await syncData()
})
// Cancel a specific job:
job.cancel()
// Reschedule:
job.reschedule("*/30 * * * *") // Now runs every 30 minutes
// Check next invocation:
console.log("Next run:", job.nextInvocation())
Croner
Croner — production-grade cron with TypeScript-native types and robust error handling:
Basic usage
import { Cron } from "croner"
// Schedule with automatic error handling:
const job = Cron("0 * * * *", { catch: true }, async () => {
await refreshPackageData()
})
// catch: true — catches errors and continues running (won't kill the cron job)
// With timezone (DST-aware):
const job2 = Cron(
"0 9 * * *",
{ timezone: "America/New_York" },
async () => {
await sendDailyReport()
}
)
// Croner correctly handles DST — 9 AM stays 9 AM even when clocks change
Error handling and retry
import { Cron } from "croner"
import { logger } from "./logger"
const job = Cron(
"0 * * * *",
{
timezone: "UTC",
catch: (err, job) => {
// Custom error handler:
logger.error({ err, jobName: job.name }, "Cron job failed")
notifySlack(`Cron failed: ${err.message}`)
},
name: "hourly-sync", // Named jobs for identification
},
async () => {
await syncPackageData()
}
)
Start/stop and status
const job = Cron("* * * * *", { paused: true }, () => {
console.log("tick")
})
job.resume() // Start running
// Check status:
console.log(job.isRunning()) // Currently executing
console.log(job.isStopped()) // Has been stopped
console.log(job.nextRun()) // Next run time (Date object)
console.log(job.currentRun()) // Currently running (or null)
console.log(job.previousRun()) // Last run time (Date object)
// Stop after N runs:
let count = 0
const limitedJob = Cron("* * * * *", () => {
count++
if (count >= 5) limitedJob.stop() // Stop after 5 runs
})
Multiple schedules for the same handler
import { Cron } from "croner"
// Run at different schedules but same handler:
const handler = async () => {
await checkApiHealth()
}
Cron("*/5 9-17 * * 1-5", handler) // Every 5 min, 9-5 on weekdays
Cron("*/15 * * * 6,0", handler) // Every 15 min on weekends
Feature Comparison
| Feature | node-cron | node-schedule | Croner |
|---|---|---|---|
| Cron syntax | ✅ | ✅ | ✅ |
| TypeScript | ✅ @types | ✅ @types | ✅ Native |
| Timezone | ✅ | ✅ | ✅ DST-aware |
| Date scheduling | ❌ | ✅ | ✅ |
| RecurrenceRule | ❌ | ✅ | ❌ |
| Error catching | ❌ Manual | ❌ Manual | ✅ catch option |
| Named jobs | ❌ | ✅ | ✅ |
| Pause/resume | ✅ | ✅ | ✅ |
| Next run query | ❌ | ✅ | ✅ |
| Second precision | ✅ | ✅ | ✅ |
| Active maintenance | ✅ | ✅ | ✅ |
When to Use Each
Choose node-cron if:
- Simple scripts with standard cron schedules
- You're already familiar with it and it works
- Minimal setup is priority — just import and schedule
Choose node-schedule if:
- You need to schedule jobs at specific dates (not just recurring)
- Complex recurrence rules (every 30 min between 8-6 on weekdays)
- You need the "run once on this specific date" pattern
Choose Croner if:
- Production applications where silent failures are a problem
- DST-sensitive schedules (schedules at 9 AM must always run at 9 AM, not 8 or 10)
- You want built-in error handling with custom error callbacks
- TypeScript-first development — Croner's types are native, not @types additions
Consider BullMQ or Inngest if:
- Jobs need to survive server restarts (stored in Redis/Inngest)
- Distributed task execution across multiple servers
- Job history, retry logic, and observability are requirements
Timezone Handling and Daylight Saving Time Edge Cases
Timezone-aware scheduling is one of the most error-prone aspects of cron jobs in production, and the three libraries handle it with varying degrees of correctness that become apparent only when clocks change.
The core problem is that most cron libraries internally work with UTC timestamps and timezone offsets, then convert to the target timezone to determine when to fire. When daylight saving time transitions occur, a naive implementation can fire a "9 AM" job at 8 AM or 10 AM, skip a scheduled run entirely, or fire it twice. These bugs are insidious because they surface only twice a year and are often dismissed as one-off issues until they cause a critical failure in production.
Croner's timezone handling is the most robust of the three. It uses the Intl API (built into modern JavaScript engines) to resolve timezone-aware timestamps, which correctly handles DST transitions, historical timezone changes, and the irregular transition rules in some jurisdictions. Croner's documentation explicitly tests against DST edge cases and treats timezone-correct behavior as a first-class feature. When you schedule a job at "0 9 * * *" with timezone: "America/New_York", it fires at 9:00 AM Eastern time every day, including the days when EST transitions to EDT and back.
Node-cron and node-schedule also accept a timezone option, and both use the moment-timezone or native Intl API under the hood depending on version. The difference is in edge case handling: node-cron's timezone support was added as a post-hoc feature and has historically had issues with the hour that gets skipped or repeated during DST transitions. Before using node-cron for timezone-sensitive production schedules, test explicitly around DST transition dates for your target timezone.
For scheduling tasks in UTC (common in data pipelines where the job always runs at the same UTC wall-clock time), all three libraries are equivalent and timezone edge cases are irrelevant.
Running Cron Jobs Across Multiple Server Instances
In-process cron libraries run on whatever server process hosts them. When your application scales horizontally to multiple instances — three Fly.io VMs, a Kubernetes deployment with 5 replicas, or multiple Heroku dynos — every instance will run every cron job on its own schedule. A daily data sync job that runs correctly on one instance will run five times on five instances, causing duplicate emails, redundant database writes, or race conditions in data processing.
The standard solutions fall into two categories: leader election and distributed locks.
Leader election designates one instance as the cron runner. If that instance dies, a new leader is elected. This is complex to implement correctly from scratch but is handled by libraries like redis-leader or can be done via database advisory locks in PostgreSQL. Once leader election is working, cron jobs run only on the leader instance using any of the three libraries.
Distributed locks are simpler for specific jobs: before executing a cron job's logic, acquire a Redis lock with a TTL slightly longer than the job's expected run time. If the lock acquisition fails, another instance already acquired it and is running the job — skip this invocation. If the acquisition succeeds, run the job and release the lock when done. This requires adding Redis to your stack but is a robust solution compatible with any of the three cron libraries.
The cleanest architectural solution is to move scheduled jobs out of the web application process entirely and into dedicated infrastructure: BullMQ workers, Inngest functions, or a dedicated cron service. These platforms handle the distributed scheduling problem natively. For production applications where job correctness is critical, this separation is worth the additional complexity.
Observability: Knowing When Cron Jobs Fail or Drift
Silent cron job failures are one of the most common sources of data integrity issues in production Node.js applications. Without active monitoring, a failed hourly sync job can go unnoticed for hours or days.
Node-cron and node-schedule have no built-in alerting or monitoring — every job failure is completely silent unless your job function explicitly catches errors and reports them. The pattern of wrapping every cron job in a try-catch with a logging call and an alerting notification is necessary but easy to forget. A missed await in an async job handler means unhandled promise rejections that may not even appear in your error logs depending on your Node.js unhandled rejection configuration.
Croner's catch option provides the minimal required safety net: errors in the job function are caught and passed to your error handler rather than silently swallowed or crashing the process. This is the correct default behavior and makes Croner meaningfully safer for production use without requiring every developer to remember to add error handling.
For deeper observability, integrating with a cron monitoring service like Healthchecks.io, Cronitor, or Betteruptime adds "heartbeat" monitoring on top of any library. The pattern is simple: at the start of your cron job, ping the monitoring service; if it doesn't receive a ping within the expected interval, it pages you. This catches both job failures (the job runs but fails before pinging) and job non-runs (the server crashed, the cron scheduler silently stopped, or the job was accidentally unscheduled).
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on node-cron v3.x, node-schedule v2.x, and Croner v8.x.
Compare task scheduling and automation packages on PkgPulse →
See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).