# Node.js Cron Job Monitoring with node-cron and node-schedule

By Dan Ochoa · Published 2025-12-04 · Updated 2026-10-19
Canonical: https://cronradar.com/blog/node-js-cron-monitoring

> Node.js cron jobs fail quietly: errors get logged or crash the process, and jobs vanish on restart. Monitor node-cron and node-schedule jobs that break.

**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](https://cronradar.com/blog/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](https://cronradar.com/blog/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:

```javascript
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](https://nodecron.com/event-listening.html).

**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`:

```javascript
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](https://nodejs.org/api/events.html#error-events). Always attach an `error` listener to node-schedule jobs.

Either way, handle errors where you can act on them:

```javascript
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:

```javascript
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](https://cronradar.com/blog/cron-expression).

### 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.

```javascript
// 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](https://cronradar.com/blog/prevent-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](https://nodecron.com/distributed-coordination.html).

## Approaches to Monitoring

### Approach 1: Structured logging

Log each execution in a format your logging platform can query:

```javascript
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](https://cronradar.com/blog/cron-dead-mans-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](https://cronradar.com/blog/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](https://docs.cronradar.com/integrations/node) 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:

```javascript
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:

```javascript
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](https://cronradar.com/nodejs) summarizes coverage.

#### Other services

- **Healthchecks.io** is open source and can be self-hosted. Jobs ping a URL, with optional `/start` and `/fail` variants.
- **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](https://cronradar.com/blog/better-stack-heartbeats) covers its period and grace settings, the `/fail` URL, and pricing.
- **Sentry Crons** fits if you already use Sentry for errors:

```javascript
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:

```javascript
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`:

```yaml
# 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.

```javascript
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:

```javascript
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.

```javascript
if (process.env.RUN_CRON === 'true') {
  require('./cron-jobs')
}
```

With PM2, run that instance as its own app:

```javascript
// 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:

```javascript
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:

```javascript
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:

```javascript
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](https://cronradar.com/blog/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:

```javascript
// 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.
