Monitoring node-cron and other Node.js schedulers

Updated November 3, 2026

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

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

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.

More guides