Node apps schedule work in two styles, and they fail in opposite directions. node-cron (and its siblings like cron) schedules inside your process: simple, zero infrastructure, and the schedule dies with the process — silently, every time, by design. BullMQ moves schedules into Redis as repeatable jobs: durable, multi-instance-safe, and now the silence has three new sources — a queue with no worker attached, a promotion process that isn't running, and a Redis eviction policy that can delete your schedules outright.

Different failure anatomy, same observable, same fix as Celery Beat, Heroku Scheduler, and plain cron before them: when the job stops completing on schedule, something outside the process must be expecting proof.

node-cron: the schedule that dies with the process

Everything node-cron does happens inside your app's event loop, which gives it a tidy failure catalog:

  • Process death is schedule death. A crash, an OOM kill, a dyno restart — while the process is down, fires are skipped, and there's no persistence and no catch-up. The job that didn't run at 02:00 doesn't run at 02:05 when the process comes back; it just doesn't run.
  • Every instance fires. Scale to two replicas and every schedule runs twice — node-cron has no coordination. The classic symptom is duplicate emails that started the day you added a second instance.
  • A blocked event loop delays fires. Long synchronous work pushes scheduled callbacks late — the same pathology as any Node latency problem, but here it silently shifts your schedule.
  • The registration is code. A refactor that drops the cron.schedule() call deletes the job with no ceremony. (At least it's in the diff — unlike dashboard-state schedulers.)

The heartbeat is the last line of the callback, success path only:

import cron from "node-cron";

cron.schedule("0 * * * *", async () => {
  await generateHourlyReport();   // throws → no ping

  await fetch("https://cronalert.com/api/heartbeat/YOUR_TOKEN");
});

Create a heartbeat monitor with a matching expected interval, and every failure above — dead process, deleted registration, delayed fire — collapses into "the ping didn't arrive." One caution unique to in-process scheduling: an unhandled rejection inside a cron callback can take down the whole app in modern Node, so keep the job body in a try/catch that reports the error and skips the ping. Loud errors and silent-absence detection are complements, not alternatives.

BullMQ: durable schedules, new silences

BullMQ's repeatable jobs (and the newer Job Scheduler API) store the schedule in Redis, which fixes node-cron's problems and introduces its own:

  • No worker, no execution — and no error. Redis holds the schedule; only an attached Worker executes jobs and advances the repetition. Deploy a change that stops starting the worker process, scale it to zero, or point it at a differently-named queue, and repeatable jobs stall indefinitely while the producer side looks perfectly healthy. This is the "scheduled but never consumed" failure with the roles collapsed into one library.
  • The legacy promotion trap. Older Bull and early BullMQ setups needed a separate QueueScheduler instance running to promote delayed and repeatable jobs. Modern BullMQ folded this into the Worker — but if you're maintaining an older codebase, "the QueueScheduler process silently wasn't running" is a famous whodunit. If your delayed jobs sit frozen in the delayed set, check this first.
  • Redis eviction eats schedules. BullMQ requires maxmemory-policy noeviction; on a Redis configured with an LRU policy (common on shared or default cloud instances), memory pressure can evict BullMQ keys — deleting job state and schedules with no error on the application side. Jobs don't fail; they cease to exist. Check your policy today, and see monitoring Redis for the memory side.
  • Changing a repeatable creates a twin. A repeatable job's identity includes its repeat options — edit the cron pattern and you've added a second schedule while the old one keeps firing. The Job Scheduler API's upsertJobScheduler fixes this with stable IDs; on the older API you must remove the old repeatable explicitly. Twice-arriving heartbeat pings are the cheap tell, same as duplicate Celery Beats.
  • Exhausted retries land quietly. A job that fails all its attempts moves to the failed set and sits there. Retries are a reason alerts should be silence-based: the heartbeat pings only when a run finally succeeds, so it stays quiet through recoverable flapping and fires exactly when the job is truly not completing.

The worker-side heartbeat

Ping at the end of the processor, success path only — never at enqueue time. "Scheduled" is not "done":

import { Worker } from "bullmq";

const worker = new Worker("reports", async (job) => {
  if (job.name === "daily-digest") {
    await sendDailyDigest(job.data);

    // Success signal — only reached if the work completed
    await fetch("https://cronalert.com/api/heartbeat/YOUR_TOKEN");
  }
});

The canary scheduler

Per-job heartbeats catch a daily job's miss tomorrow. To learn today that the whole machinery — Redis, the schedule, the worker — is broken, run a canary through the same pipeline:

// Producer: a 5-minute schedule whose only job is proof-of-life
await queue.upsertJobScheduler("bullmq-canary", { every: 300_000 }, {
  name: "canary",
});

// Worker:
if (job.name === "canary") {
  await fetch("https://cronalert.com/api/heartbeat/CANARY_TOKEN");
}

A 5-minute heartbeat monitor on that token now verifies the full chain end to end. Route the canary through the same Redis and the same queue as your real jobs — a canary on its own pristine queue proves a chain your real work doesn't use. This is the same pattern as the Celery beat-liveness canary, and it's the first monitor to add.

If you run node-cron to enqueue BullMQ jobs — a common hybrid — note that you've put an in-process scheduler back at the front of a durable pipeline. The canary should start at the node-cron end so the whole chain is covered.

Set it up in ten minutes

  • Create a CronAlert account — heartbeat monitors are on the Pro plan ($5/mo, 100 monitors, 1-minute checks).
  • Add the canary first (a 5-minute repeatable, or a 5-minute node-cron task if that's your scheduler), with a matching heartbeat monitor.
  • Add an end-of-processor ping to each scheduled job that matters, expected interval matching each schedule.
  • Check maxmemory-policy on every Redis that backs BullMQ — today, not during the incident.
  • Route alerts to Slack or email, then stop the worker on staging and confirm the canary alert arrives — a fire drill for the pipeline.

Frequently asked questions

Why did my node-cron job stop running?

The process it lived in stopped, restarted through a fire, or lost the registration in a deploy — all invisible from inside. Heartbeat at the end of the job; the missing ping is the alert.

Why are my BullMQ repeatable jobs not running?

No worker attached, a legacy QueueScheduler not running, or Redis evicted the schedule (set noeviction). All three collapse into a missed heartbeat.

Why is my repeatable job running twice?

Changed repeat options register a new schedule alongside the old one. Use upsertJobScheduler with stable IDs, or remove the old repeatable explicitly. Duplicate pings are the tell.

node-cron or BullMQ?

node-cron for single-instance, loss-tolerant tasks; BullMQ for multi-instance, retries, and persistence. Both need heartbeats — neither can report its own death.

Durable or in-process, silence is still the failure mode

Moving schedules from the process into Redis trades one set of silent failures for another — it doesn't buy you detection. A ping at the end of each job and a canary through the real pipeline do, in about ten lines total. Set up CronAlert and wire the canary first.

Related reading: monitoring background workers and queue depth, monitoring Celery Beat, monitoring Redis and ElastiCache, uptime monitoring for Express and Node.js, and cron job heartbeat monitoring.