Node.js gives developers a rich ecosystem of scheduler libraries: node-cron, Agenda, node-schedule, cron-schedule, BullMQ. They're all excellent for getting jobs running. None of them tell you when a job stops.
This article covers the specific failure modes unique to Node.js schedulers, how to add proper error handling, and how to wire up heartbeat monitoring so you get alerted before your users notice.
Traditional crontab failures are mostly about the scheduler not executing. Node.js scheduler failures are sneakier — the scheduler does execute, but the job itself swallows the error and exits cleanly. Three patterns cause this constantly:
This is the most common silent failure mode in Node.js scheduled jobs. If your job function is async and throws inside a .then() chain without a catch, the rejection floats unhandled. In Node.js 14 and earlier this was completely silent. In newer versions it terminates the process — but with node-cron, the scheduler process dies, taking all other jobs with it:
// This silently drops the error in older Node; crashes everything in newer Node
cron.schedule('0 * * * *', async () => {
const result = await fetchDataFromApi(); // throws on network failure
await saveToDatabase(result); // never reached — no alert fired
});
node-cron and node-schedule store schedules in memory. When your Node process restarts — deploy, OOM kill, PM2 restart — all scheduled jobs are re-registered from your startup code. But if a deploy fails halfway, or your startup crashes after the scheduler initializes but before all jobs are registered, you end up with a subset of your jobs silently missing. No error, no log line, no alert.
Agenda.js jobs emit 'fail' events that are completely silent unless you listen for them explicitly. Same with BullMQ's 'failed' queue event. Most tutorials show you how to define a job — they don't show you what happens when it throws:
// Agenda — the fail event is silently swallowed without this listener
agenda.on('fail', (err, job) => {
console.error(`Agenda job ${job.attrs.name} failed:`, err);
// Without this, you'll never know the job errored
});
// BullMQ — same pattern
worker.on('failed', (job, err) => {
console.error(`BullMQ job ${job?.name} failed:`, err);
});
Every node-cron job should wrap its body in a try/catch and send a heartbeat ping on success. The heartbeat is what makes silent failures detectable from the outside:
import cron from 'node-cron';
import fetch from 'node-fetch';
cron.schedule('0 * * * *', async () => {
try {
await runMyJob();
// Signal successful completion to your monitoring service
await fetch('https://your-monitor/heartbeat/YOUR_TOKEN', {
method: 'GET',
signal: AbortSignal.timeout(5000), // don't let the ping hang the job
});
} catch (err) {
console.error('Job failed:', err);
// Optionally: notify an error tracker like Sentry here
}
});
Key details: the heartbeat fires only on success (inside try, not finally). The ping has a 5-second timeout so a slow monitoring endpoint never blocks your job. The catch logs the error so it appears in your process logs.
The heartbeat pattern works like this: your job pings a URL at the end of each successful run. A monitoring service tracks those pings. If it doesn't receive a ping within the expected window (e.g., within 70 minutes of an hourly job), it fires an alert.
This is called a dead man's switch. It catches three failure classes with one mechanism:
You get notified for all three with a single fetch() call inside the try block.
agenda.define('send-weekly-digest', async (job) => {
await generateAndSendDigest();
// Heartbeat — only fires on successful completion
await fetch('https://your-monitor/heartbeat/AGENDA_DIGEST_TOKEN', {
signal: AbortSignal.timeout(5000),
});
});
// Always attach a fail listener — Agenda won't surface errors otherwise
agenda.on('fail', (err, job) => {
console.error(`[Agenda] ${job.attrs.name} failed:`, err);
});
Whether you're using node-cron, Agenda, BullMQ, node-schedule, or cron-schedule, the heartbeat pattern is the same: run the job body, ping the monitor on success, wrap in error handling. The scheduler library doesn't matter. The monitoring service doesn't care what dispatched the job — it only cares whether the ping arrived on time.
When you register a monitor, you set a grace period: how long after the expected run time to wait before alerting. The right value depends on your job's typical runtime and how much latency is acceptable.
A few practical guidelines:
If you're not sure how long your job actually takes, check your process logs for the last 10 runs and set the grace to 1.5× the 95th-percentile duration.
async () => { await ... } without error handling.fail event listener if you use Agenda — one line, immediately after agenda.define().fetch() call with a 5-second timeout inside the try block.CronWatch monitors scheduled jobs across every Node.js scheduler — node-cron, Agenda.js, BullMQ, node-schedule, and cron-schedule. Add a single heartbeat ping to each job and get alerted in Slack or email within minutes of a missed window or failure.
Join the CronWatch waitlist → Parse a cron expression