Monitor BullMQ Job Schedulers and Repeatable Jobs
Updated
BullMQ can produce jobs on a cron pattern or a fixed interval, and its workers retry failures with backoff. What it can't do is tell you that a scheduled job didn't happen. That can be because no worker is connected, Redis lost its data, the scheduler was removed by a deploy script, or the worker is stuck on a long job and the queue backs up. Failed jobs at least appear in the failed set. Missing jobs appear nowhere.
A heartbeat check adds that signal. The worker reports each scheduled run to Cuckoo, and a late or failed report triggers an alert.
How Cuckoo sees a BullMQ schedule
Create one check per job scheduler, with the same cron pattern and time zone. Each check has a URL:
https://cuckoo.metastic.ch/ping/YOUR-UUID/startwhen the worker picks up the jobhttps://cuckoo.metastic.ch/ping/YOUR-UUIDwhen it completeshttps://cuckoo.metastic.ch/ping/YOUR-UUID/failwhen it fails for good (POST body up to 10 KB stored)
Step 1: Define the schedule
Since BullMQ 5.16, job schedulers are the recommended API. upsertJobScheduler is idempotent, so you can call it
on every boot without creating duplicates:
import { Queue } from "bullmq";
const connection = { host: "127.0.0.1", port: 6379 };
const queue = new Queue("reports", { connection });
await queue.upsertJobScheduler(
"nightly-report", // scheduler id
{ pattern: "0 3 * * *", tz: "Europe/Zurich" }, // or { every: 60_000 }
{
name: "nightly-report",
data: {},
opts: { attempts: 3, backoff: { type: "exponential", delay: 30_000 } },
}
);
On older BullMQ versions the equivalent is a repeatable job:
queue.add("nightly-report", {}, { repeat: { pattern: "0 3 * * *", tz: "Europe/Zurich" } }). BullMQ v6 removes
the legacy repeatable-job APIs, so migrate to job schedulers before you upgrade.
Step 2: A ping helper
// 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}`);
}
}
Step 3: Ping from the worker
Map job names to ping URLs, send /start from the processor, and use the worker's completed and failed
events for the result:
import { Worker } from "bullmq";
import { ping } from "./heartbeat.js";
const PINGS = {
"nightly-report": process.env.CUCKOO_NIGHTLY_REPORT, // https://cuckoo.metastic.ch/ping/YOUR-UUID
};
const worker = new Worker(
"reports",
async (job) => {
await ping(PINGS[job.name], "/start");
return buildNightlyReport(job.data);
},
{ connection: { host: "127.0.0.1", port: 6379 } }
);
worker.on("completed", (job) => {
ping(PINGS[job.name]);
});
worker.on("failed", (job, err) => {
if (!job) return;
const maxAttempts = job.opts.attempts ?? 1;
if (job.attemptsMade >= maxAttempts) {
ping(PINGS[job.name], "/fail", `${err.name}: ${err.message}\n${err.stack ?? ""}`);
}
});
You can also put all pings inside the processor with try/catch. The events version has one advantage: it can
see whether BullMQ will retry the job.
Attempts, backoff and fail pings
With attempts: 3, a failing job runs up to three times. The failed event fires after every failed attempt,
so the check above sends /fail only once attemptsMade has reached the limit. Attempts that later succeed simply
end in a success ping.
Two things to size correctly:
- Grace time vs. backoff. With
attempts: 3, exponential backoff with a 30-second base waits about 30 s and then 60 s. The grace time on the Cuckoo check must be longer than the total retry window plus runtime. If it isn't, the check goes late before the final attempt can report. - Alert on every attempt instead. Drop the
attemptsMadecondition. Cuckoo then alerts on the first failure and sends a recovery message when a retry succeeds.
Why this also catches a dead worker
If no worker is running, the scheduler still produces jobs in Redis, but nobody processes them and no ping is sent. If Redis is down, nothing is produced at all. Either way the check goes late after the grace time. Several workers on the same queue are fine: each scheduled job is processed by one worker, so it pings once.
Schedule and grace advice
- Mirror
patternandtzin Cuckoo as a cron expression with the same IANA time zone. Forevery, use a simple period of the same length. - BullMQ patterns may have a seconds field (
0 15 3 * * *). Cuckoo cron expressions use five fields, so drop the leading seconds. - Set a max runtime if a job can stall. With the
/startping, Cuckoo flags runs that never finish.
Troubleshooting
- No pings, no jobs: run
queue.getJobSchedulers()to check that the scheduler exists in this Redis database. - Jobs waiting but not processed: the worker connects to a different queue name, prefix or Redis database.
/failon every attempt:job.opts.attemptsis not set on the scheduler's job template, so it defaults to one.- Duplicate schedulers after deploys: you are still on
repeatoptions with changing settings. Move toupsertJobSchedulerwith a fixed id.
FAQ
Do the completed and failed events work across processes?
The Worker events used here fire only in the worker process that ran the job, which is the right place for
pings. If you need events from elsewhere, BullMQ's QueueEvents class receives them through Redis.
Can one check cover several schedulers?
Technically yes, but then you can't tell which one stopped. Use one check per scheduler id.
Does a failing ping affect the job?
No. The helper catches every error and times out after 10 seconds, and the event handlers don't await anything that could throw back into BullMQ.
Related guides
- Monitor APScheduler Jobs in Python with CuckooMonitor APScheduler 3.x jobs with Cuckoo: CronTrigger with timezone, misfire_grace_time and max_instances, plus pings from listeners or the job itself.
- Monitor Go Cron Jobs and robfig/cron SchedulesMonitor Go scheduled jobs with Cuckoo: a ping helper using net/http and context timeouts, robfig/cron/v3 with CRON_TZ, and standalone binaries run by cron.
- Monitor PHP Cron Scripts with CuckooMonitor PHP CLI scripts run by cron: ping Cuckoo with curl or file_get_contents, catch fatal errors with register_shutdown_function, and set exit codes.
- Monitor node-cron and Croner Jobs in Node.jsMonitor node-cron and Croner scheduled jobs in Node.js with fetch and AbortSignal.timeout pings, and get alerted when the whole Node process goes down.