TL;DR
node-cron is the simplest cron scheduler — pure in-memory, cron syntax, no database needed, perfect for simple recurring tasks in a single-process app. node-schedule is more flexible — supports both cron and date/recurrence rule syntax, and can schedule one-off jobs at specific times. Agenda is the most production-ready — persists jobs in MongoDB, handles distributed systems (multiple server instances), and retries failed jobs. In 2026: node-cron for simple cron tasks, Agenda (or BullMQ) for persistent, reliable job scheduling in production.
Key Takeaways
- node-cron: ~3M weekly downloads — in-memory, cron syntax, no persistence, timezone support
- node-schedule: ~2M weekly downloads — cron + date rules, flexible scheduling, no persistence
- agenda: ~300K weekly downloads — MongoDB-backed persistence, distributed, retries, job management UI
- In-memory schedulers (node-cron, node-schedule) lose jobs on process restart — use for idempotent tasks
- Agenda stores jobs in MongoDB — survived restarts, tracks history, supports job priorities
- For Redis-backed scheduling, see BullMQ (covered separately)
Cron Syntax Quick Reference
┌──────────── minute (0-59)
│ ┌────────── hour (0-23)
│ │ ┌─────── day of month (1-31)
│ │ │ ┌──── month (1-12)
│ │ │ │ ┌─ day of week (0-6, 0=Sunday)
│ │ │ │ │
* * * * *
Examples:
"0 * * * *" — every hour at :00
"*/15 * * * *" — every 15 minutes
"0 9 * * 1-5" — 9am Monday-Friday
"0 0 1 * *" — midnight on the 1st of each month
"0 0 * * 0" — midnight every Sunday
"*/5 9-17 * * 1-5" — every 5min, 9am-5pm, Mon-Fri
node-cron
node-cron — cron scheduler for Node.js:
Basic scheduling
import cron from "node-cron"
// Schedule a task with cron syntax:
cron.schedule("0 * * * *", () => {
// Runs every hour at :00
console.log("Hourly task running at", new Date().toISOString())
syncPackageDownloads()
})
// Every 15 minutes:
cron.schedule("*/15 * * * *", async () => {
await refreshHealthScores()
})
// Every day at 2am:
cron.schedule("0 2 * * *", async () => {
await generateDailyReport()
await cleanupOldLogs()
})
// Weekdays at 9am:
cron.schedule("0 9 * * 1-5", () => {
sendWeeklyDigest()
})
Timezone support
import cron from "node-cron"
// Schedule in a specific timezone:
cron.schedule(
"0 9 * * 1-5",
() => {
sendMorningDigest()
},
{
timezone: "America/New_York", // Runs at 9am ET, not server time
}
)
// Schedule at midnight UTC regardless of server timezone:
cron.schedule(
"0 0 * * *",
() => {
runMidnightJob()
},
{
timezone: "UTC",
}
)
Task control
import cron from "node-cron"
// Get a reference to the task:
const task = cron.schedule("*/5 * * * *", () => {
processQueue()
})
// Stop the task:
task.stop()
// Start (or restart) the task:
task.start()
// Destroy (can't restart after this):
task.destroy()
// Validate a cron expression:
const isValid = cron.validate("*/5 * * * *") // true
const invalid = cron.validate("*/5 * * * * *") // false (node-cron is 5-field only)
Prevent overlap
import cron from "node-cron"
let isRunning = false
cron.schedule("*/5 * * * *", async () => {
// Prevent overlapping execution if task takes longer than interval:
if (isRunning) {
console.log("Previous run still in progress, skipping")
return
}
isRunning = true
try {
await longRunningTask()
} catch (err) {
console.error("Task failed:", err)
} finally {
isRunning = false
}
})
node-schedule
node-schedule — flexible scheduling with cron and recurrence rules:
Cron-style scheduling
import schedule from "node-schedule"
// Cron syntax (same as node-cron):
const job = schedule.scheduleJob("0 0 * * *", () => {
runMidnightJob()
})
// Cancel:
job.cancel()
// Reschedule:
job.reschedule("0 6 * * *")
Recurrence rules (non-cron)
import schedule from "node-schedule"
// Recurrence Rule — more readable than cron strings:
const rule = new schedule.RecurrenceRule()
rule.dayOfWeek = [new schedule.Range(0, 6)] // Every day
rule.hour = 14 // At 2pm
rule.minute = 30 // At :30
const job = schedule.scheduleJob(rule, () => {
sendAfternoonUpdate()
})
// Day 1 of every month at 3am:
const monthlyRule = new schedule.RecurrenceRule()
monthlyRule.date = 1 // 1st of month
monthlyRule.hour = 3
monthlyRule.minute = 0
monthlyRule.tz = "America/Chicago"
schedule.scheduleJob(monthlyRule, () => {
generateMonthlyReport()
})
One-off scheduling (specific date/time)
import schedule from "node-schedule"
// Schedule a job at a specific future time:
const fireDate = new Date(2026, 2, 15, 9, 0, 0) // March 15, 2026 at 9am
const job = schedule.scheduleJob(fireDate, () => {
sendLaunchAnnouncement()
})
// Fires once at the exact time, then the job is done
// Schedule relative to now:
const in30Minutes = new Date(Date.now() + 30 * 60 * 1000)
const reminder = schedule.scheduleJob(in30Minutes, () => {
sendReminder(userId)
})
// Cancel before it fires:
reminder.cancel()
Multiple named jobs
import schedule from "node-schedule"
// Named jobs for management:
schedule.scheduleJob("hourly-sync", "0 * * * *", syncData)
schedule.scheduleJob("daily-cleanup", "0 3 * * *", cleanup)
schedule.scheduleJob("weekly-report", "0 9 * * 1", weeklyReport)
// Access by name:
const job = schedule.scheduledJobs["hourly-sync"]
console.log("Next invocation:", job.nextInvocation())
// Cancel all:
schedule.gracefulShutdown()
Agenda
Agenda — MongoDB-backed job scheduler:
Setup
import Agenda from "agenda"
const agenda = new Agenda({
db: {
address: process.env.MONGODB_URI!,
collection: "agendaJobs",
},
processEvery: "30 seconds", // How often to check for jobs to run
defaultLockLifetime: 10 * 60 * 1000, // 10 minute lock (for long jobs)
})
// Define job handlers:
agenda.define("sync package downloads", async (job) => {
const { packageName } = job.attrs.data
await syncDownloadsForPackage(packageName)
})
agenda.define("send weekly digest", async (job) => {
const { userId, email } = job.attrs.data
await sendDigestEmail(userId, email)
})
agenda.define("cleanup old data", async (job) => {
const deleted = await db.auditLog.deleteMany({
createdAt: { $lt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) },
})
job.attrs.result = { deleted: deleted.count }
})
// Start processing:
await agenda.start()
Recurring jobs
// Schedule recurring jobs:
await agenda.every("1 hour", "sync package downloads", { packageName: "react" })
await agenda.every("1 day at 2:00am", "cleanup old data")
// Cron syntax also works:
await agenda.every("0 9 * * 1-5", "send weekly digest", { userId: "all", email: "digest@example.com" })
// Remove existing schedule and create fresh:
await agenda.every("5 minutes", "sync package downloads", {}, {
skipImmediate: true, // Don't run immediately on startup
})
One-off jobs
// Schedule a one-off job:
await agenda.schedule("in 30 minutes", "send welcome email", {
userId: "user_123",
email: "user@example.com",
})
await agenda.schedule("next Monday at 9am", "send weekly digest", {
userId: "user_123",
})
// Run now:
await agenda.now("sync package downloads", { packageName: "vue" })
Job concurrency and locking
// Control how many concurrent instances of a job run:
agenda.define("import packages", { concurrency: 2 }, async (job) => {
// Max 2 instances running at once across all server instances
const { batchId } = job.attrs.data
await importPackageBatch(batchId)
})
// Priority — higher numbers run first:
agenda.define("critical-sync", { priority: "high" }, async (job) => {
await criticalSync()
})
// Priority values: "highest" (20), "high" (10), "normal" (0), "low" (-10), "lowest" (-20)
Error handling and retries
agenda.define("send email", async (job) => {
const { to, subject, body } = job.attrs.data
try {
await sendEmail(to, subject, body)
} catch (err) {
// Fail the job — agenda records the error:
throw err
}
})
// Listen for job events:
agenda.on("success:send email", (job) => {
console.log(`Email sent to ${job.attrs.data.to}`)
})
agenda.on("fail:send email", (err, job) => {
console.error(`Failed to send email to ${job.attrs.data.to}:`, err.message)
// Agenda stores the failure in MongoDB — you can query failed jobs
})
// Manually retry failed jobs:
const failedJobs = await agenda.jobs({ failCount: { $gt: 0 }, name: "send email" })
for (const job of failedJobs) {
await job.run()
}
Query and manage jobs
// Find all scheduled jobs:
const allJobs = await agenda.jobs({})
// Find specific jobs:
const emailJobs = await agenda.jobs({
name: "send email",
"data.to": "user@example.com",
})
// Cancel jobs:
await agenda.cancel({ name: "send email", "data.userId": "user_123" })
// Purge completed jobs older than 7 days:
await agenda.purge() // Removes completed, failed jobs
// Get job stats:
const pending = await agenda.jobs({ nextRunAt: { $lte: new Date() } })
console.log(`${pending.length} jobs pending`)
Feature Comparison
| Feature | node-cron | node-schedule | Agenda |
|---|---|---|---|
| Persistence | ❌ In-memory | ❌ In-memory | ✅ MongoDB |
| Survives restart | ❌ | ❌ | ✅ |
| Distributed (multiple servers) | ❌ | ❌ | ✅ (locking) |
| Cron syntax | ✅ | ✅ | ✅ |
| One-off scheduling | ❌ | ✅ | ✅ |
| Job history/logs | ❌ | ❌ | ✅ |
| Retry failed jobs | ❌ | ❌ | ✅ |
| Job concurrency control | ❌ | ❌ | ✅ |
| Requires MongoDB | ❌ | ❌ | ✅ |
| TypeScript | ✅ | ✅ | ✅ |
| Weekly downloads | ~3M | ~2M | ~300K |
When to Use Each
Choose node-cron if:
- Simple recurring tasks in a single-process app
- Tasks are idempotent — safe to re-run from scratch after restart
- No need for job history, retries, or distributed coordination
- Minimal setup: just
npm install node-cron
Choose node-schedule if:
- Need to schedule one-off tasks at specific future dates/times
- Prefer recurrence rule API over cron syntax
- Simple use case, single process, no persistence needed
Choose Agenda if:
- Production app where jobs must survive restarts
- Running multiple server instances (horizontally scaled)
- Need job failure tracking and retry logic
- Want a job management interface (see
agenda-restor Agendash UI) - Critical jobs (emails, payments, notifications) that cannot be dropped
Consider BullMQ instead if:
- Already using Redis in your stack (better performance than MongoDB for queues)
- Need high-throughput job processing (millions of jobs)
- Want more sophisticated queue patterns (rate limiting, delayed jobs, repeatable jobs)
Distributed Scheduling and Multi-Instance Deployments
Modern production applications rarely run as a single process — horizontal scaling means multiple instances of the same service run concurrently. In-memory schedulers like node-cron and node-schedule run in every instance, meaning a job scheduled to fire hourly will fire on every active instance simultaneously. For idempotent jobs this is acceptable — running a health check or refreshing an in-process cache once per instance is fine. For non-idempotent jobs — sending a weekly digest email, charging a subscription, or generating an invoice — running the same job on three server instances causes duplicate execution. Agenda solves this through MongoDB-based locking: before executing a job, Agenda acquires a lock in MongoDB. If another instance already holds the lock for that job, the second instance skips execution. This distributed lock mechanism is the core feature that makes Agenda suitable for multi-instance production deployments. BullMQ's repeat option provides the same guarantee using Redis for locking. Teams deploying to Kubernetes with multiple replicas should default to Agenda or BullMQ rather than in-memory schedulers for any non-idempotent job.
Job Failure Recovery and Alerting
Production job scheduling requires visibility into failures and a strategy for recovery. node-cron and node-schedule have no built-in failure tracking — if a job throws, the error is emitted to the process's uncaughtException handler unless you catch it within the job callback. You must instrument job failures manually by wrapping job logic in try-catch and forwarding errors to your monitoring system (Sentry, Datadog, PagerDuty). Agenda stores job execution results in MongoDB, including failure details, failure counts, and the last run timestamp. Querying await agenda.jobs({ failCount: { $gt: 0 } }) gives you a list of all failed jobs across all instances and all time, which is invaluable for diagnosing recurring issues. Agenda's fail:jobName event allows you to attach real-time failure handlers — connecting these to Slack notifications or PagerDuty integrations is a common pattern for production systems. For critical jobs where failure requires immediate human attention (payment processing, compliance reporting), Agenda's event system and MongoDB-backed job history make it significantly easier to implement proper operational alerting than in-memory schedulers.
Job Scheduling as Infrastructure: Kubernetes CronJobs
An alternative to embedded job schedulers is externalizing scheduled tasks to infrastructure-level primitives. Kubernetes CronJobs run a containerized job on a schedule defined in the cluster configuration, giving you independent scaling, resource limits, and failure restart policies separate from the main application. This approach has compelling advantages: the job scheduler is not embedded in a web server process (so web server restarts or deployments do not affect scheduled job execution), each job run gets its own container with defined CPU and memory limits, and Kubernetes handles distributed locking automatically (only one pod runs per trigger). The tradeoff is operational complexity — defining, deploying, and monitoring Kubernetes CronJobs requires more infrastructure knowledge than adding node-cron to an existing application. For teams already running on Kubernetes with infrastructure engineers, Kubernetes CronJobs are often the better architectural choice for critical scheduled operations; for teams on serverless platforms (Vercel, Netlify), platform-native cron features or external services like Inngest and Trigger.dev provide managed scheduling without Kubernetes overhead.
Timezone Handling Edge Cases
Timezone handling in job scheduling has edge cases that catch teams off guard. Daylight saving time transitions cause clocks to "spring forward" or "fall back," which can cause jobs scheduled at certain times to either fire twice or not fire at all. A job scheduled for "2:30am every Sunday" in a timezone that observes DST will not fire on the night clocks spring forward from 2:00am to 3:00am — the 2:30am slot simply does not exist that night. node-cron's timezone support uses the moment-timezone library internally, which handles DST transitions correctly by always using wall clock time in the specified timezone. node-schedule uses the cron-parser package with similar DST awareness. Agenda relies on the timing of when the job is scheduled in MongoDB — the nextRunAt field is stored as a UTC timestamp, and the scheduler fires when the current UTC time exceeds it, which correctly handles DST since UTC never has DST transitions. For jobs that must fire at exact local clock times regardless of DST, all three libraries handle this correctly when the timezone option is set. For jobs where you want exactly one execution per 24-hour period regardless of clock time (daily data sync, nightly cleanup), expressing the schedule in UTC avoids DST edge cases entirely.
Monitoring Scheduled Job Health in Production
Regardless of which library you choose, monitoring scheduled job execution is essential for production reliability. Silent failures — jobs that are scheduled but never run, or jobs that run but produce no output — are among the hardest production issues to detect without proactive monitoring. A common pattern for all three libraries is a "heartbeat" approach: each scheduled job, upon successful completion, writes a timestamp to a health check endpoint or monitoring service. Tools like Healthchecks.io or Cronitor provide hosted "dead man's switch" monitoring — you register a job's expected cadence, and the service alerts you if a check-in does not arrive within the expected window. For Agenda specifically, the MongoDB job history provides a persistent log that can be queried by a monitoring dashboard. For node-cron and node-schedule, implementing this monitoring requires wrapping every job callback with timing and success/failure instrumentation. Making monitoring part of the initial job implementation rather than adding it after an incident is consistently the more reliable approach.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on node-cron v3.x, node-schedule v2.x, and agenda v5.x.
Compare task automation and scheduling packages on PkgPulse →
See also: cac vs meow vs arg 2026 and cosmiconfig vs lilconfig vs conf, archiver vs adm-zip vs JSZip (2026).