Why is my cron job not running
A missed run is a scheduled instant with no row in the run ledger. Compute the instants for the cron expression, then diff them against the rows the scheduler wrote. The audit below reports due 23 recorded 15 failed 0 missing 8 and names every instant that never fired, which separates a stopped process from a wrong schedule.
Why check this
Run this when a report did not arrive, after a deploy that restarts the scheduler, and on staging sign-off for any service that owns a schedule. A run that never starts writes nothing: no error line, no failed status to alert on. The invoice job in the window below was due 23 times and ran 15. Eight instants left no trace except in the difference between the schedule and the ledger, and the customer noticed before the monitoring did.
Prerequisites
- Node 22 and
cron-parser, installed withnpm i cron-parser. Version 5.4.0 produced every figure here. - A run ledger. If the scheduler appends no row per fire, add one first: an absent run leaves nothing in the job's own logs.
- The crontab(5) manual for field order, and the timezone the scheduler resolves the expression in.
- No cron daemon is used here. The target is the Node scheduler below, shaped like a scheduler inside an application: parse, wait, fire, record.
// scheduler.mjs <expression> <timezone> <ledger>
import { appendFileSync } from 'node:fs';
import { CronExpressionParser } from 'cron-parser';
const [expr, tz, ledger] = process.argv.slice(2);
const jobMs = Number(process.env.JOB_MS ?? 200);
let running = false;
function scheduleFrom(from) {
const next = CronExpressionParser.parse(expr, { tz, currentDate: from }).next().toDate();
setTimeout(() => { fire(next); scheduleFrom(next); }, next - Date.now());
}
async function fire(due) {
if (running) return console.log(`skipped ${due.toISOString()} previous run still going`);
running = true;
const startedAt = new Date().toISOString();
let status = 'ok';
try {
await new Promise((ok, no) => setTimeout(() => (process.env.JOB_FAIL ? no(new Error('job threw')) : ok()), jobMs));
} catch (e) { status = e.message; }
appendFileSync(ledger, JSON.stringify({ due: due.toISOString(), startedAt, status }) + '\n');
running = false;
}
console.log(`scheduler pid ${process.pid} expression ${expr} zone ${tz}`);
scheduleFrom(new Date());
// audit.mjs <expression> <timezone> <windowStart> <windowEnd> <ledger>
import { readFileSync, existsSync } from 'node:fs';
import { CronExpressionParser } from 'cron-parser';
const [expr, tz, from, to, ledger] = process.argv.slice(2);
const t0 = Date.parse(from), t1 = Date.parse(to);
const rows = existsSync(ledger)
? readFileSync(ledger, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
: [];
const seen = new Map(rows.filter((r) => Date.parse(r.due) >= t0 && Date.parse(r.due) <= t1).map((r) => [r.due, r.status]));
const it = CronExpressionParser.parse(expr, { tz, currentDate: from, endDate: to });
const due = [];
try { for (;;) due.push(it.next().toDate().toISOString()); } catch { /* window end */ }
const missing = due.filter((d) => !seen.has(d));
const failed = due.filter((d) => seen.has(d) && seen.get(d) !== 'ok');
console.log(`due ${due.length} recorded ${seen.size} failed ${failed.length} missing ${missing.length}`);
for (const d of missing) console.log(`missing ${d}`);
for (const d of failed) console.log(`failed ${d} ${seen.get(d)}`);
The expression below fires every five seconds, so the page reproduces in four minutes. A production schedule is minute-level and the method is the same.
Steps
- Step 1.
Start the scheduler and note the process id it prints.
node scheduler.mjs '*/5 * * * * *' UTC runs.jsonlscheduler pid 16488 expression */5 * * * * * zone UTCRecord the process id. Step 4 stops that exact process and nothing else.
- Step 2.
After 30 seconds, read the ledger.
cat runs.jsonl{"due":"2026-09-12T18:08:20.000Z","startedAt":"2026-09-12T18:08:20.005Z","status":"ok"} {"due":"2026-09-12T18:08:25.000Z","startedAt":"2026-09-12T18:08:25.010Z","status":"ok"} {"due":"2026-09-12T18:08:30.000Z","startedAt":"2026-09-12T18:08:30.007Z","status":"ok"} {"due":"2026-09-12T18:08:35.000Z","startedAt":"2026-09-12T18:08:35.012Z","status":"ok"} {"due":"2026-09-12T18:08:40.000Z","startedAt":"2026-09-12T18:08:40.009Z","status":"ok"} {"due":"2026-09-12T18:08:45.000Z","startedAt":"2026-09-12T18:08:45.004Z","status":"ok"}dueis the instant the schedule asked for,startedAtis when the job began. The audit keys ondue, so a run a few milliseconds late still matches. - Step 3.
Audit a window in which the process stayed up the whole time.
node audit.mjs '*/5 * * * * *' UTC '2026-09-12T18:08:18Z' '2026-09-12T18:08:48Z' runs.jsonldue 6 recorded 6 failed 0 missing 0This is the control. An audit that cannot report zero on a healthy window is measuring its own defect.
- Step 4.
Take the scheduler down across several instants, then audit the wider window.
node audit.mjs '*/5 * * * * *' UTC '2026-09-12T18:08:18Z' '2026-09-12T18:10:12Z' runs.jsonldue 23 recorded 15 failed 0 missing 8 missing 2026-09-12T18:08:50.000Z missing 2026-09-12T18:08:55.000Z missing 2026-09-12T18:09:00.000Z missing 2026-09-12T18:09:05.000Z missing 2026-09-12T18:09:10.000Z missing 2026-09-12T18:09:15.000Z missing 2026-09-12T18:09:20.000Z missing 2026-09-12T18:09:25.000ZProcess 16488 was stopped with
Stop-Process -Id 16488 -Forceat 18:08:49Z and a new scheduler started at 18:09:26Z. The missing block sits between those timestamps: an outage removes an unbroken run of instants. - Step 5.
Start a second scheduler whose job outlasts its interval.
JOB_MS=7000 node scheduler.mjs '*/5 * * * * *' UTC runs-overlap.jsonlscheduler pid 2836 expression */5 * * * * * zone UTC skipped 2026-09-12T18:10:35.000Z previous run still going skipped 2026-09-12T18:10:45.000Z previous run still going skipped 2026-09-12T18:10:55.000Z previous run still going skipped 2026-09-12T18:11:05.000Z previous run still goingThe job takes 7 seconds on a 5 second schedule, so the overlap guard drops every second instant.
- Step 6.
Audit that window and compare the shape with step 4.
node audit.mjs '*/5 * * * * *' UTC '2026-09-12T18:10:29Z' '2026-09-12T18:11:07Z' runs-overlap.jsonldue 8 recorded 4 failed 0 missing 4 missing 2026-09-12T18:10:35.000Z missing 2026-09-12T18:10:45.000Z missing 2026-09-12T18:10:55.000Z missing 2026-09-12T18:11:05.000ZHalf the schedule is gone and the process never stopped. Alternating instants mean a skip, a solid block means an outage, and only the scheduler log separates them.
- Step 7.
Audit a window in which every run started and threw. That ledger came from a third scheduler started with
JOB_FAIL=1.node audit.mjs '*/5 * * * * *' UTC '2026-09-12T18:11:23Z' '2026-09-12T18:11:45Z' runs-fail.jsonldue 5 recorded 5 failed 5 missing 0 failed 2026-09-12T18:11:25.000Z job threw failed 2026-09-12T18:11:30.000Z job threw failed 2026-09-12T18:11:35.000Z job threw failed 2026-09-12T18:11:40.000Z job threw failed 2026-09-12T18:11:45.000Z job threwNothing is missing. Every instant fired and the work failed inside the job, which no schedule change will fix.
- Step 8.
Print the instants an expression produces across a daylight saving boundary, with the local wall clock and the gap.
node -e "const { CronExpressionParser } = require('cron-parser'); const tz = 'America/New_York'; const it = CronExpressionParser.parse('30 2 * * *', { tz, currentDate: '2027-03-12T00:00:00Z', endDate: '2027-03-16T00:00:00Z' }); let prev = null; try { for (;;) { const d = it.next().toDate(); console.log(d.toISOString(), d.toLocaleString('en-GB', { timeZone: tz, hour12: false }), prev ? ((d - prev) / 3600000).toFixed(0) + 'h' : ''); prev = d; } } catch {}"2027-03-12T07:30:00.000Z 12/03/2027, 02:30:00 2027-03-13T07:30:00.000Z 13/03/2027, 02:30:00 24h 2027-03-14T07:30:00.000Z 14/03/2027, 03:30:00 24h 2027-03-15T06:30:00.000Z 15/03/2027, 02:30:00 23hLocal 02:30 does not exist on 14 March 2027, so cron-parser 5.4.0 placed that run at 03:30, and the next gap is 23 hours rather than 24.
- Step 9.
Audit the schedule on its own, over a window with no ledger row inside it.
node audit.mjs '0 3 30 * *' UTC '2027-02-01T00:00:00Z' '2027-03-01T00:00:00Z' runs-fail.jsonldue 0 recorded 0 failed 0 missing 00 3 30 * *matches the 30th and February 2027 has 28 days. Nothing ran because nothing was due. Check this before you look at the scheduler.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| due 6 recorded 6 missing 0 | Every instant in the window has a row | Widen the window before closing the ticket. The gap may be older. |
| One unbroken block of missing instants | The scheduler process was not running across that block | Line the first and last missing instants up against service start and stop times. |
| missing instants alternating with recorded ones | The previous run was still going and the overlap guard dropped the instant | Read the scheduler log for a skip line, then shorten the job or queue the instant. |
| failed N with missing 0 | The runs happened and the job threw | Fix the job. The schedule is correct. |
| due 0 | No instant matched the expression in that window | The run was never due. Correct the expression, not the host. |
| Missing on one date only, near a 02:00 local boundary | The clock moved that day | Compare the wall clock of the expected instants across the transition, as step 8 does. |
| Every instant on a transition day is missing | The audit resolved the expression in a different zone from the scheduler | Pass the scheduler's zone to the audit. Your terminal's zone is not it. |
Common mistakes
What to check next
- How to check if a cron job ran: one instant instead of a window.
- How to check if a cron job is still running: step 5 without the guard, two copies running.
- How to check cron schedule drift: runs that happen late, which this audit counts as present.
- How to validate cron expression: the parse behind a
due 0result. - How to check if a queue worker is running: whether the process that fires the next instant is alive.
FAQ
Why is my cron job not running at the scheduled time?
Four causes produce the same silence: the process was down, the previous run was still going and the instant was skipped, the clock moved, or the expression never matched. The audit separates them by the shape of the missing instants and by due 0.
How to check failed cron jobs?
A failed run and a missed run are different rows. Step 7 reports failed 5 missing 0: every instant fired and the job threw. Record a status per run, or the two collapse into one unexplained absence.
Does a missed run execute later?
Not by default. The scheduler here drops the instant and waits for the next one: the eight instants in step 4 were never retried. If the work must not be skipped, enqueue the instant instead of executing it.
How do I tell an outage from a skipped run?
By the pattern. An outage removes an unbroken block of instants, as in step 4; an overlap guard removes alternating instants, as in step 6. A skip prints a scheduler log line, an outage prints nothing.
Verified
Verified by Maks Vernynode 22.23.2cron-parser 5.4.0Windows PowerShell 5.1.22621.6133
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: cron parse expression, next runs
- All background jobs and queues checks
intermediate10 minpublished updated Maks Verny