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

// 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

  1. Step 1.

    Start the scheduler and note the process id it prints.

    node scheduler.mjs '*/5 * * * * *' UTC runs.jsonl
    
    scheduler pid 16488 expression */5 * * * * * zone UTC

    Record the process id. Step 4 stops that exact process and nothing else.

  2. 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"}

    due is the instant the schedule asked for, startedAt is when the job began. The audit keys on due, so a run a few milliseconds late still matches.

  3. 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.jsonl
    
    due 6 recorded 6 failed 0 missing 0

    This is the control. An audit that cannot report zero on a healthy window is measuring its own defect.

  4. 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.jsonl
    
    due 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.000Z

    Process 16488 was stopped with Stop-Process -Id 16488 -Force at 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.

  5. Step 5.

    Start a second scheduler whose job outlasts its interval.

    JOB_MS=7000 node scheduler.mjs '*/5 * * * * *' UTC runs-overlap.jsonl
    
    scheduler 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 going

    The job takes 7 seconds on a 5 second schedule, so the overlap guard drops every second instant.

  6. 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.jsonl
    
    due 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.000Z

    Half 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.

  7. 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.jsonl
    
    due 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 threw

    Nothing is missing. Every instant fired and the work failed inside the job, which no schedule change will fix.

  8. 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 23h

    Local 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.

  9. 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.jsonl
    
    due 0 recorded 0 failed 0 missing 0

    0 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

Sign: A monitor that alerts when 24 hours pass with no run fires twice a year for no reason.Cause: The gap between daily runs is not always 24 hours. cron-parser 5.4.0 put 30 2 * * * in America/New_York 23 hours apart across 15 March 2027, and 30 1 * * * 25 hours apart across 8 November 2027. Key the alert to the expected instants, not to a fixed interval.
Sign: The audit reports a missing run on the spring transition and the scheduler insists it ran.Cause: For 30 2 * * * on 14 March 2027 the parser emitted 07:30Z, which is 03:30 local, because 02:30 did not exist that day. A scheduler that resolves the skipped hour by a different rule records another instant, so the match fails for that date alone.
Sign: A run that was killed halfway looks the same as a run that never started.Cause: This ledger appends its row after the job returns, so a process stopped mid-run leaves nothing behind. Write one row when the run starts and a second when it ends, or accept that the ledger answers whether the work finished, not whether it began.
Sign: A schedule described as every 90 minutes produces 24 runs a day and nothing is reported missing.Cause: cron-parser 5.4.0 accepted */90 * * * * and returned 672 instants across February 2027, one an hour. A step wider than the field range collapses to the first value. The audit agrees with the expression, and the expression is wrong.

What to check next

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.

intermediate10 minpublished updated Maks Verny