node-cron vs node-schedule vs Agenda: Job Scheduling in Node.js (2026)
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)
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 →