Back to Blog
December 24, 2024

How to Monitor Node.js Cron Jobs (With Alerts)

Node.js has a dozen ways to run scheduled tasks: node-cron, Agenda, Bull, node-schedule, or just setInterval. They all share the same problem: when something breaks, nobody knows. Your sync job crashes at 3 AM, the process restarts, and life goes on. Meanwhile, your data is three days stale.

This guide shows how to add heartbeat monitoring to Node.js scheduled tasks so failures don't go unnoticed.

This guide covers Node.js 18+ (with built-in fetch). For earlier versions, see notes inline.

Why console.error Isn't Monitoring

Most Node.js scheduled tasks have error handling like this:

cron.schedule('0 2 * * *', async () => {
  try {
    await runBackup();
  } catch (error) {
    console.error('Backup failed:', error);
  }
});

This catches crashes. It doesn't catch the job never running at all. If your Node process dies and doesn't restart, or PM2 misconfigures the app, or the server reboots without starting your service, there's no error to catch. Nothing happens, and you don't know.

The Solution: Heartbeat Monitoring

Alert on the absence of success, not the presence of failure.

After your job completes successfully, ping an external URL. If that ping doesn't arrive on schedule, you get alerted. This catches every failure mode: crashes, process death, server issues, OOM kills.

Monitoring node-cron Tasks

node-cron is the most common scheduler for Node.js. Adding monitoring is straightforward.

Basic Implementation

const cron = require('node-cron');

async function pingMonitor(checkId) {
  try {
    await fetch(`https://api.cronsignal.io/ping/${checkId}`);
  } catch (err) {
    console.error('Monitor ping failed:', err.message);
  }
}

cron.schedule('0 2 * * *', async () => {
  try {
    await runBackup();
    await pingMonitor('YOUR_CHECK_ID');
  } catch (error) {
    console.error('Backup failed:', error);
    // Don't ping - CronSignal will alert
  }
});

The ping only fires if runBackup() succeeds. If it throws, no ping, you get alerted.

Using Node 18+ fetch

Node 18 and later have built-in fetch:

// Node 18+ has built-in fetch
// For Node 16 and earlier, install node-fetch:
//   npm install node-fetch
//   const fetch = require('node-fetch');

cron.schedule('0 * * * *', async () => {
  await generateReports();
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
});

Clean and simple.

Using axios

If you're already using axios:

const axios = require('axios');

cron.schedule('*/15 * * * *', async () => {
  await syncInventory();
  await axios.get('https://api.cronsignal.io/ping/YOUR_CHECK_ID', {
    timeout: 5000
  });
});

Reusable Wrapper

For multiple monitored jobs, create a wrapper:

function monitoredJob(checkId, jobFn) {
  return async () => {
    try {
      await jobFn();
      await fetch(`https://api.cronsignal.io/ping/${checkId}`);
    } catch (error) {
      console.error(`Job failed:`, error);
      // No ping - monitoring will alert
    }
  };
}

// Usage
cron.schedule('0 2 * * *', monitoredJob('BACKUP_CHECK', runBackup));
cron.schedule('0 * * * *', monitoredJob('REPORT_CHECK', generateReports));
cron.schedule('*/5 * * * *', monitoredJob('SYNC_CHECK', syncData));

Monitoring Bull Queue Jobs

Bull is popular for Redis-backed job queues. For recurring jobs with Bull:

// Bull 3.x (legacy but widely used)
const Queue = require('bull');

// For BullMQ (Bull 4.x / current):
// const { Queue } = require('bullmq');

const reportQueue = new Queue('reports');

// Add recurring job
reportQueue.add(
  {},
  {
    repeat: { cron: '0 6 * * 1' } // Every Monday 6 AM
  }
);

// Process with monitoring
reportQueue.process(async (job) => {
  await generateWeeklyReport();

  // Ping on success
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');

  return { success: true };
});

// Optional: ping on failure for redundant alerting
reportQueue.on('failed', async (job, err) => {
  console.error('Job failed:', err);
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID/fail');
});

Monitoring Agenda Jobs

Agenda is another popular MongoDB-backed scheduler:

const Agenda = require('agenda');
const agenda = new Agenda({ db: { address: mongoUri } });

agenda.define('backup database', async (job) => {
  await runBackup();

  // Ping on success
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
});

(async function() {
  await agenda.start();
  await agenda.every('0 2 * * *', 'backup database');
})();

Monitoring PM2 Cron

PM2 keeps your Node process running, but doesn't tell you if the process is healthy. Add a heartbeat to prove your scheduled tasks are actually executing:

// In your main process
const cron = require('node-cron');

// Heartbeat every 5 minutes proves the process is alive
cron.schedule('*/5 * * * *', async () => {
  await fetch('https://api.cronsignal.io/ping/PROCESS_HEARTBEAT');
});

Set CronSignal to expect this every 5 minutes. If PM2 fails to keep your process running, the heartbeat stops.

Serverless: AWS Lambda Scheduled Events

For Lambda functions triggered by EventBridge (CloudWatch Events):

exports.handler = async (event) => {
  try {
    await processData();

    // Ping on success
    await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');

    return { statusCode: 200 };
  } catch (error) {
    console.error('Lambda failed:', error);
    throw error; // Let Lambda handle retries
  }
};

This works because if the Lambda fails, it either retries or logs the failure. But if EventBridge itself has issues, or the Lambda is deleted, no ping arrives and you get alerted.

Monitoring Long-Running Jobs

For jobs that take significant time, ping at start and end:

cron.schedule('0 2 * * *', async () => {
  // Ping start
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID/start');

  // Long-running task
  await runETLPipeline();

  // Ping completion
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
});

If you get a start ping but no completion, the job is hanging or crashed mid-execution.

Error Handling Best Practices

Don't swallow errors. If something fails, let it fail loudly:

// Bad: Swallows errors, still pings
cron.schedule('0 2 * * *', async () => {
  try {
    await riskyOperation();
  } catch (e) {
    console.error(e);
  }
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID'); // Wrong!
});

// Good: Only pings on success
cron.schedule('0 2 * * *', async () => {
  await riskyOperation(); // Throws on failure
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
});

If you need to handle errors for cleanup, re-throw after:

cron.schedule('0 2 * * *', async () => {
  try {
    await riskyOperation();
    await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
  } catch (error) {
    await cleanup();
    throw error; // Re-throw so monitoring sees the failure
  }
});

Testing Your Setup

Before trusting your monitoring, verify it works.

Run your job manually (or wait for it to run on schedule). Check CronSignal to confirm the ping arrived.

Then test failure detection. Make your job throw an error:

cron.schedule('* * * * *', async () => {
  throw new Error('Test failure');
  await fetch('https://api.cronsignal.io/ping/YOUR_CHECK_ID');
});

Verify no ping arrives and you get an alert within your grace period.

Getting Started

CronSignal handles the monitoring side for $9/month. Create a check, grab your ping URL, add it to your Node.js scheduled tasks. Takes five minutes. Start with 3 checks free.

Whether you're using node-cron, Bull, Agenda, or Lambda, the pattern is identical: ping on success, get alerted on absence.

Other schedulers: Bree and Croner are newer alternatives to node-cron with additional features. The monitoring pattern is identical for any scheduler.


For more on heartbeat monitoring and why it beats try/catch error logging, see our guide on how to monitor cron jobs.

Ready to monitor your cron jobs?

$9/month for unlimited checks

Get Started