Cuckoo

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/start when the job starts
  • https://cuckoo.metastic.ch/ping/YOUR-UUID when it succeeds
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/fail when 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 /start without a finish. Set a max runtime on the check so that this becomes visible.

Schedule and grace advice

  • Use the same cron expression and timezone in Cuckoo as in your code. Without timezone, 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.
  • TimeoutError in logs: outbound HTTPS to cuckoo.metastic.ch is 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 timezone explicitly 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

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.