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
Workerexecutes 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
QueueSchedulerinstance 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
upsertJobSchedulerfixes 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-policyon 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 (or for Bun, where these patterns apply unchanged), and cron job heartbeat monitoring.