Monitor node-cron and Croner Jobs in Node.js
Updated
In-process schedulers like node-cron and croner are convenient: one npm install, a cron expression, a
callback. They also have a basic weakness. The schedule exists only while your Node process is running. If the
process crashes, gets OOM-killed, or is scaled down to zero replicas, the jobs just stop. Errors thrown inside a
callback often end up as an unhandled rejection in a log stream nobody watches.
A heartbeat check covers both cases. Every run reports to Cuckoo. If the report is late, because the job failed or because the whole process is gone, you get an alert.
How Cuckoo sees a Node job
Create one check per scheduled job, with the same cron expression and time zone. Each check has a URL:
https://cuckoo.metastic.ch/ping/YOUR-UUID/startwhen the job startshttps://cuckoo.metastic.ch/ping/YOUR-UUIDwhen it succeedshttps://cuckoo.metastic.ch/ping/YOUR-UUID/failwhen it fails (a POST body of up to 10 KB is stored)
Cuckoo sits outside your process, so it notices missing pings even when your code can't run at all.
Step 1: A ping helper with fetch
Node 18+ ships fetch. AbortSignal.timeout() makes sure a slow network never blocks the job, and the catch
makes sure a failing ping never fails it:
// heartbeat.js
export async function ping(url, suffix = "", body) {
if (!url) return;
try {
await fetch(url + suffix, {
method: body ? "POST" : "GET",
body: body ? String(body).slice(0, 10_000) : undefined,
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
console.warn(`heartbeat ping failed: ${err.message}`);
}
}
export function monitored(url, fn) {
return async (...args) => {
await ping(url, "/start");
try {
const result = await fn(...args);
await ping(url);
return result;
} catch (err) {
await ping(url, "/fail", err?.stack ?? String(err));
throw err;
}
};
}
monitored() wraps any async function. It sends /start, then a success or failure ping, and re-throws errors
so your own error handling still sees them.
Step 2a: node-cron
import cron from "node-cron";
import { monitored } from "./heartbeat.js";
const PING = process.env.CUCKOO_NIGHTLY_REPORT; // https://cuckoo.metastic.ch/ping/YOUR-UUID
cron.schedule(
"0 3 * * *",
monitored(PING, async () => {
await buildNightlyReport();
}),
{ timezone: "Europe/Zurich", name: "nightly-report", noOverlap: true }
);
timezone takes an IANA name. noOverlap (node-cron v4) skips a run while the previous one is still busy. A
skipped run sends no ping, so a stuck job turns into a late check. node-cron v3 has no noOverlap, so
leave it out there.
Step 2b: Croner
import { Cron } from "croner";
import { monitored } from "./heartbeat.js";
const PING = process.env.CUCKOO_HOURLY_SYNC; // https://cuckoo.metastic.ch/ping/YOUR-UUID
new Cron(
"15 * * * *",
{ timezone: "Europe/Zurich", protect: true, name: "hourly-sync", catch: true },
monitored(PING, async () => {
await syncInventory();
})
);
protect: true blocks overlapping runs. catch: true keeps an exception in the callback from becoming an
unhandled rejection. The wrapper has already reported it to Cuckoo by then.
The process crash caveat
This is the main reason to use a heartbeat with in-process schedulers. Your error handler can report a thrown exception, but not a dead process. When Node exits, the only remaining signal is that the next ping doesn't arrive. Cuckoo treats that as late and alerts you after the grace time.
Two practical consequences:
- Run the scheduler in exactly one process. With PM2 cluster mode or several Kubernetes replicas, every instance runs every job. Use a dedicated single-replica worker, or a lock, so each run pings once.
- Graceful shutdown cuts off jobs. A deploy that kills the process mid-run leaves a
/startwithout a finish. Set a max runtime on the check so that this becomes visible.
Schedule and grace advice
- Use the same cron expression and
timezonein Cuckoo as in your code. Withouttimezone, both libraries use the process time zone, which in containers is usually UTC. - Grace time is roughly the longest normal runtime plus a margin, for example 10 minutes for a job that usually takes 2.
- For very frequent jobs (every minute), give the check a short grace time like 2 to 3 minutes, so you hear about a dead process quickly.
Troubleshooting
fetch is not defined: you are on Node 16 or older. Upgrade to a supported Node version.TimeoutErrorin logs: outbound HTTPS tocuckoo.metastic.chis blocked or slow. The job still runs.- Pings arrive in pairs: more than one process runs the scheduler. See the caveat above.
- Wrong hour after a DST change: set
timezoneexplicitly in code and the same zone in Cuckoo.
FAQ
Should I use node-cron or Croner?
Both work with the same wrapper. Croner has no dependencies and built-in overlap protection. node-cron v4 added
noOverlap. Pick the one your project already uses.
Is a system cron job better than an in-process scheduler?
System cron (or a Kubernetes CronJob) starts a fresh process for each run, so a crashed run doesn't affect the next one. In-process schedulers are simpler to deploy. Both should be monitored, because neither tells you when a job silently stops running.
Can I send the job's output to Cuckoo?
Yes. Pass a string as the third argument to ping() to send it as a POST body. Cuckoo stores the first 10 KB
with the ping, which is useful for a short summary or an error stack.
Related guides
- Monitor APScheduler Jobs in Python with CuckooMonitor APScheduler 3.x jobs with Cuckoo: CronTrigger with timezone, misfire_grace_time and max_instances, plus pings from listeners or the job itself.
- Monitor BullMQ Job Schedulers and Repeatable JobsMonitor BullMQ job schedulers and older repeatable jobs: ping Cuckoo from worker events or the processor, and only alert once attempts are used up.
- Monitor Go Cron Jobs and robfig/cron SchedulesMonitor Go scheduled jobs with Cuckoo: a ping helper using net/http and context timeouts, robfig/cron/v3 with CRON_TZ, and standalone binaries run by cron.
- Monitor PHP Cron Scripts with CuckooMonitor PHP CLI scripts run by cron: ping Cuckoo with curl or file_get_contents, catch fatal errors with register_shutdown_function, and set exit codes.