Monitoring node-cron and other Node.js schedulers
Libraries like node-cron, node-schedule, croner or @nestjs/schedule make it tempting to keep scheduled jobs inside the Node.js app: no crontab, no extra container, the job shares the app’s code and config. The catch is that the schedule lives only as long as the process does, and a failure inside the callback is a log line at best.
What goes wrong with in-process schedulers
- The process isn’t running at the scheduled time. A crash loop, a deploy or a scale-to-zero platform means there’s nobody to fire the job. The run isn’t caught up later.
- Every replica runs the job. Three pods or a PM2 cluster with four workers send the same email four times.
- Errors disappear. A rejected promise in an async callback becomes an unhandled rejection: a warning in the log, or a crash of the whole app, depending on the Node version and settings.
- Runs overlap. A job that sometimes takes longer than its interval starts again while the previous run is still going.
- Server time zone. Without a time zone option the schedule follows the server’s clock, which is UTC on most hosting.
Wrap every job
This helper works with any scheduler. It needs Node.js 18 or newer for the built-in fetch. It reports the start, the result and the error, skips a run while the previous one is still going, and never lets a failed ping break the job:
// monitored.js async function ping(url, body) { try { await fetch(url, { method: body ? "POST" : "GET", body, signal: AbortSignal.timeout(10_000) }); } catch { // monitoring must never break the job } } export function monitored(url, job) { let running = false; return async () => { if (running) return; // the previous run is still going running = true; await ping(`${url}/start`); try { await job(); await ping(url); } catch (err) { await ping(`${url}/fail`, String(err?.stack ?? err).slice(0, 100_000)); } finally { running = false; } }; }
Errors are caught inside the wrapper, so an unhandled rejection can’t take the app down, and the error text appears in Heartcron’s event log.
node-cron
import cron from "node-cron";
import { monitored } from "./monitored.js";
cron.schedule(
"0 3 * * *",
monitored("https://ping.heartcron.com/<uuid>", sendDailyDigest),
{ timezone: "America/New_York" },
);
NestJS
import { Injectable } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { monitored } from "./monitored";
@Injectable()
export class ReportsService {
private readonly digest = monitored("https://ping.heartcron.com/<uuid>", () => this.sendDailyDigest());
@Cron("0 3 * * *", { timeZone: "America/New_York" })
run() {
return this.digest();
}
}
In Heartcron, create a check with the same expression, 0 3 * * *, and the same time zone. Set the grace period a bit longer than a normal run. If the app was down at 3:00, no ping arrives, and you hear about it.
One scheduler, not one per replica
- PM2 cluster mode sets
NODE_APP_INSTANCEfor each worker. Start the schedules only where it’s"0". - Kubernetes or several containers: run the scheduler in a separate one-replica deployment, guard each job with a lock in Postgres (
pg_try_advisory_lock) or Redis, or move the job to a Kubernetes CronJob.
Heartcron’s event log helps check this: if one run in the schedule shows up as several pings a few milliseconds apart, more than one process is firing it.
Or move it out of the process
A job that must run even when the app is down is better as a separate script started by the system cron, a container scheduler or a platform’s cron feature. Then the open-source heartcron CLI can wrap it without the helper above:
0 3 * * * heartcron run daily-digest -- node /srv/app/scripts/digest.js
It sends the start, the exit code, the duration and the last lines of output. Check what an expression means in the cron expression explainer.