Node.js Cron Job Monitoring with node-cron and node-schedule
Updated
Contents
To monitor a Node.js cron job, handle each run's errors explicitly, record when it starts and finishes, and have something outside the process alert you when an expected run doesn't arrive. node-cron and node-schedule run jobs inside your Node.js process. When the process stops, so do the jobs, and nothing reports the runs that didn't happen.
This guide covers node-cron 4 and node-schedule 2, the current major versions. For a Worker that Cloudflare runs on a schedule instead, see Cloudflare Cron Triggers. To compare node-cron and node-schedule with cron, croner, Agenda, and BullMQ on syntax, time zones, persistence, and multi-instance behavior, see Node.js schedulers compared.
How node-cron and node-schedule Run Jobs#
node-cron schedules a function against a cron expression and returns a task you can start(), stop(), destroy(), or run immediately with execute(). Version 4 has options such as noOverlap, maxExecutions, and timezone, and lifecycle events you can listen to:
const cron = require('node-cron')
const options = { name: 'hourly-cache-refresh', timezone: 'UTC', noOverlap: true }
const task = cron.schedule('0 * * * *', () => refreshCache(), options)
task.on('execution:failed', (ctx) => {
console.error('hourly-cache-refresh failed:', ctx.execution?.error?.message)
})The events include execution:started, execution:finished, execution:failed, execution:missed (the process woke too late for a run, for example because of blocking work or high CPU), and execution:overlap (a run was skipped because noOverlap was set and the previous run was still going). See the node-cron events reference.
node-schedule keeps a single timer for the next due job and supports cron strings, dates, and RecurrenceRule objects with a time zone. Jobs emit run after each execution, plus success and error:
const schedule = require('node-schedule')
const rule = new schedule.RecurrenceRule()
rule.dayOfWeek = [1, 3, 5] // Mon, Wed, Fri
rule.hour = 14
rule.minute = 30
rule.tz = 'America/New_York'
const job = schedule.scheduleJob(rule, () => sendDigest())
job.on('success', () => console.log('Digest sent'))
job.on('error', (err) => console.error('Digest failed:', err))Both are in-process schedulers. node-schedule's README says it directly: jobs fire only while your script is running, and the schedule disappears when it stops.
Why Node.js Cron Jobs Fail Silently#
Errors are logged, or crash the process#
The two libraries handle a failing job very differently.
node-cron 4 catches a task that throws or returns a rejected promise. It logs the error through its logger, emits execution:failed, and keeps the schedule running. The process survives, but unless something reads that log or listens for the event, nobody finds out.
node-schedule emits an error event when a job throws or rejects. Node's EventEmitter treats an error event with no listener as an uncaught exception: the error is thrown and the process exits, taking every other scheduled job with it. See Node.js error events. Always attach an error listener to node-schedule jobs.
Either way, handle errors where you can act on them:
cron.schedule('0 9 * * *', async () => {
try {
await sendDailyReport()
} catch (error) {
console.error('Daily report failed:', error)
Sentry.captureException(error) // or your error tracker
throw error // let the scheduler and your monitoring see the failure too
}
})Jobs are lost on restart#
When the process restarts for a deploy, a crash, or a server reboot, every scheduled job disappears until your code registers it again. Runs that fell due while the process was down are not run later; there is no persistence to recover them from. A deploy at 2:55 AM can quietly skip the 3:00 AM backup.
node-cron's execution:missed event only covers runs the running process woke up too late for. It cannot report a run that fell due while the process wasn't running at all.
Dynamically created tasks need to be destroyed#
If you create tasks at runtime, for example one per tenant, stop() only pauses a task. destroy() removes it from node-cron's registry. Destroy tasks you no longer need, or they accumulate for the life of the process.
Time zones and daylight saving time#
A job scheduled in a zone that observes daylight saving time can be skipped when the clocks spring forward or run twice when they fall back, depending on the library and the time. Set the time zone explicitly, and prefer UTC for critical jobs:
cron.schedule('0 2 * * *', () => runBackup(), { timezone: 'UTC' })
// node-schedule
const rule = new schedule.RecurrenceRule()
rule.hour = 2
rule.minute = 0
rule.tz = 'Etc/UTC'For the cron expression fields themselves, see cron expression syntax.
Overlapping runs#
A job scheduled every minute that takes 90 seconds overlaps with itself. Database connections pile up and two runs can process the same records.
// node-cron 4: skip a run while the previous one is still going
cron.schedule('* * * * *', () => longTask(), { noOverlap: true })
// node-schedule: track it yourself
let isRunning = false
schedule.scheduleJob('* * * * *', async () => {
if (isRunning) {
console.warn('Previous execution still running, skipping')
return
}
isRunning = true
try {
await longTask()
} finally {
isRunning = false
}
})Overlap locks are one of several safeguards in preventing errors in scheduled automation.
Duplicate runs across instances#
Scale to three instances and a daily email goes out three times, because each process schedules its own copy. Run the scheduler in one designated instance, take a distributed lock per run, or move the work to a queue that coordinates workers. node-cron 4 also has a distributed option that runs each firing on one instance, using an environment variable or a coordinator such as Redis; see distributed coordination.
Approaches to Monitoring#
Approach 1: Structured logging#
Log each execution in a format your logging platform can query:
function createLoggedJob(name, schedule, task) {
cron.schedule(schedule, async () => {
const startTime = Date.now()
const executionId = crypto.randomUUID()
console.log(
JSON.stringify({
event: 'cron_job_started',
job: name,
execution_id: executionId,
scheduled_time: new Date().toISOString(),
}),
)
try {
await task()
console.log(
JSON.stringify({
event: 'cron_job_completed',
job: name,
execution_id: executionId,
duration_ms: Date.now() - startTime,
}),
)
} catch (error) {
console.error(
JSON.stringify({
event: 'cron_job_failed',
job: name,
execution_id: executionId,
duration_ms: Date.now() - startTime,
error: error.message,
}),
)
throw error
}
})
}Then alert on cron_job_failed events and on durations above a threshold. Detecting a missing cron_job_completed event is harder: your logging platform needs to know when each job was expected, and the alert has to work when the process that writes the logs is down.
Approach 2: Heartbeat monitoring#
An external monitor turns that around. It knows each job's schedule, the job reports when it starts and finishes, and the monitor alerts when a report fails or never arrives. This is the dead man's switch pattern, and it works even when the whole process is gone.
CronRadar over HTTP
NestJS apps that schedule through @nestjs/schedule (which runs on the cron package) have their own error handling and multi-instance behavior, covered in NestJS scheduled tasks. The cronradar npm package is temporarily unavailable: the published package cannot load because it is missing a required file. The Node.js integration guide carries the current availability notice. Until it returns, report over HTTP. Nothing is discovered automatically, node-cron jobs included: each job needs its own monitor key and its real schedule.
This wrapper uses the fetch built into current Node.js releases, with a five-second timeout and Basic authentication. Monitoring errors are logged and never change the job's result, and the job's own error is rethrown:
const { randomUUID } = require('node:crypto')
const CRONRADAR_PING_URL = 'https://cron.life/api/v1/ping'
async function report(monitorKey, event, params) {
const apiKey = process.env.CRONRADAR_API_KEY
if (!apiKey) return
const query = new URLSearchParams(params).toString()
const url = `${CRONRADAR_PING_URL}/${encodeURIComponent(monitorKey)}/${event}?${query}`
try {
const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Basic ${Buffer.from(`${apiKey}:`).toString('base64')}` },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) console.error(`CronRadar returned ${response.status} for ${monitorKey}`)
} catch (error) {
console.error(`CronRadar ${event} report failed for ${monitorKey}: ${error.message}`)
}
}
function monitored(monitorKey, cronSchedule, task) {
return async () => {
const runId = randomUUID()
await report(monitorKey, 'start', { schedule: cronSchedule, run_id: runId })
try {
await task()
} catch (error) {
// Keep the message generic: error details can contain data that must not leave the app.
const message = `${monitorKey} threw ${error.name}`
await report(monitorKey, 'fail', { run_id: runId, message })
throw error
}
await report(monitorKey, 'complete', { run_id: runId })
}
}Use the same key and schedule as the job it watches, and keep the job in UTC, because CronRadar evaluates schedules in UTC:
const options = { name: 'nightly-backup', timezone: 'UTC' }
cron.schedule('0 3 * * *', monitored('nightly-backup', '0 3 * * *', performBackup), options)The start report carries the schedule, so CronRadar creates the monitor the first time it sees the key. A failure report opens an incident immediately; a start without a completion and a run that never arrives are caught against the schedule. With node-schedule, keep an error listener on the job, because the wrapper rethrows. Store CRONRADAR_API_KEY in your deployment's secret configuration and use a separate CronRadar application for each environment.
Alerts go to email, Slack, webhook, Discord, or PagerDuty. Run the job once, confirm its result appears, and confirm a test alert reaches whoever will act on it. The Node.js monitoring overview summarizes coverage.
Other services
- Healthchecks.io is open source and can be self-hosted. Jobs ping a URL, with optional
/startand/failvariants. - Cronitor provides job monitoring with SDKs for several languages.
- Better Stack offers heartbeat monitors inside its incident-management platform; Better Stack heartbeats vs a dedicated cron monitor covers its period and grace settings, the
/failURL, and pricing. - Sentry Crons fits if you already use Sentry for errors:
const Sentry = require('@sentry/node')
Sentry.init({ dsn: 'your-dsn' })
cron.schedule('0 * * * *', async () => {
await Sentry.withMonitor(
'hourly-backup',
async () => {
await performBackup()
},
{
schedule: { type: 'crontab', value: '0 * * * *' },
timezone: 'UTC',
},
)
})Heartbeat services are built for detecting missing runs, at the cost of an external dependency. Compare current plans on each provider's own pricing page.
Approach 3: Build your own#
You can record executions in your database and run a checker that compares them with each job's expected schedule. The checker has to run somewhere other than the process it watches, and it needs its own alerting and its own monitoring. That is the hard part, and the reason heartbeat services exist.
Approach 4: Prometheus metrics#
If you run Prometheus, expose job metrics:
const promClient = require('prom-client')
const jobDuration = new promClient.Histogram({
name: 'cron_job_duration_seconds',
help: 'Cron job execution duration',
labelNames: ['job_name', 'status'],
})
const jobLastSuccess = new promClient.Gauge({
name: 'cron_job_last_success_timestamp',
help: 'Timestamp of last successful execution',
labelNames: ['job_name'],
})
function createMetricsJob(name, schedule, task) {
cron.schedule(schedule, async () => {
const end = jobDuration.startTimer({ job_name: name })
try {
await task()
end({ status: 'success' })
jobLastSuccess.setToCurrentTime({ job_name: name })
} catch (error) {
end({ status: 'failure' })
throw error
}
})
}Then alert on time() - cron_job_last_success_timestamp > threshold:
# Prometheus alerting rule
- alert: CronJobMissed
expr: time() - cron_job_last_success_timestamp > 7200
for: 5m
labels:
severity: warning
annotations:
summary: "Cron job {{ $labels.job_name }} hasn't run in 2+ hours"The gauge lives in process memory, so it disappears on restart until the job succeeds again. Alert when the series is absent too.
Production Patterns#
Graceful shutdown#
When the process receives SIGTERM, stop scheduling new runs and let running ones finish. node-cron 4 has this built in: shutdown() stops every task, waits for running ones up to the timeout in milliseconds, then destroys them.
process.on('SIGTERM', async () => {
await cron.shutdown(30_000)
process.exit(0)
})node-schedule's gracefulShutdown() cancels all jobs and waits until running jobs end:
process.on('SIGTERM', async () => {
await schedule.gracefulShutdown()
process.exit(0)
})One scheduler in a scaled deployment#
The simplest coordination is configuration: only one instance loads the jobs.
if (process.env.RUN_CRON === 'true') {
require('./cron-jobs')
}With PM2, run that instance as its own app:
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'app-cron',
script: './server.js',
instances: 1,
env: { RUN_CRON: 'true' },
},
{
name: 'app-web',
script: './server.js',
instances: 'max',
env: { RUN_CRON: 'false' },
},
],
}To let any instance run a job, but only one per firing, take a lock in Redis:
const Redis = require('ioredis')
const redis = new Redis()
async function acquireLock(jobName, ttlSeconds) {
const lockKey = `cron-lock:${jobName}`
const result = await redis.set(lockKey, process.pid, 'EX', ttlSeconds, 'NX')
return result === 'OK'
}
cron.schedule('0 * * * *', async () => {
const acquired = await acquireLock('hourly-job', 3600)
if (!acquired) {
console.log('Another instance has the lock, skipping')
return
}
await performJob()
})Retry with backoff#
For transient failures, retry a bounded number of times before giving up:
async function withRetry(fn, maxAttempts = 3, baseDelayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn()
} catch (error) {
if (attempt === maxAttempts) throw error
const delay = baseDelayMs * Math.pow(2, attempt - 1)
console.log(`Attempt ${attempt} failed, retrying in ${delay}ms`)
await new Promise((r) => setTimeout(r, delay))
}
}
}
cron.schedule('0 * * * *', async () => {
await withRetry(async () => {
await syncExternalData()
})
})Retry only work that is safe to repeat.
When to Move Beyond In-Process Cron#
node-cron and node-schedule suit lightweight jobs. Consider a queue such as BullMQ (Redis) or Agenda (MongoDB) when you need jobs that survive restarts, retries with a record of failures, workers that coordinate without duplicate runs, and visibility into what is queued:
const { Queue, Worker } = require('bullmq')
const connection = { host: '127.0.0.1', port: 6379 }
async function main() {
const queue = new Queue('tasks', { connection })
// Creates the recurring schedule, or updates it if it already exists
await queue.upsertJobScheduler(
'daily-report',
{ pattern: '0 9 * * *' },
{ name: 'generate-report' },
)
const worker = new Worker(
'tasks',
async (job) => {
if (job.name === 'generate-report') {
await generateReport()
}
},
{ connection },
)
worker.on('failed', (job, error) => console.error(`${job?.name} failed:`, error.message))
}
main().catch((error) => {
console.error(error)
process.exit(1)
})The tradeoff is infrastructure: a Redis or MongoDB instance to run and monitor. A queue still needs something to tell you when the scheduled job didn't run. BullMQ repeatable jobs covers Job Schedulers in production: downtime, stalled jobs, and alerts.
Keep Job Logic Testable#
Separate what a job does from when it runs, and test the logic directly:
// tasks/cleanup.js - pure logic, easy to test
async function cleanupOldRecords(olderThanDays = 30) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - olderThanDays)
const result = await db.query('DELETE FROM logs WHERE created_at < $1', [cutoff])
return result.rowCount
}
module.exports = { cleanupOldRecords }
// cron.js - just wiring
const { cleanupOldRecords } = require('./tasks/cleanup')
cron.schedule('0 3 * * *', () => cleanupOldRecords())The arrow function matters: node-cron passes its own argument to the task function, which would otherwise replace olderThanDays.
Frequently Asked Questions#
How do I monitor node-cron jobs?#
Listen for execution:failed to log and track errors, and report each run's start and finish to an external monitor that knows the job's schedule. The monitor alerts when a run fails, or when no report arrives because the process is down.
Does node-cron catch errors in async tasks?#
Yes, in node-cron 4. A task that throws or returns a rejected promise is caught, logged, and emitted as execution:failed, and the schedule keeps running. Nobody is alerted unless you handle that event or log.
Why did a failing node-schedule job crash my process?#
node-schedule emits an error event when a job throws or rejects. Without an error listener, Node treats that event as an uncaught exception and the process exits. Attach job.on('error', ...) to every job.
Do node-cron jobs run after a restart?#
Only from the next scheduled time. Schedules exist in memory, so runs that fell due while the process was down are skipped, not caught up. An external monitor reports those missed runs.
How do I stop a node-cron job running on every server?#
Load the jobs in only one instance, take a lock per run in Redis or your database, or use node-cron 4's distributed option with a coordinator. A queue such as BullMQ also coordinates workers for you.
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.