How to check if a cron job ran
Read the record the job leaves, not the scheduler's own log. Count runs and successes per job in the run table: a job that fired and threw shows runs 8, ok 0, and the scheduler still reports it as having run. Windows Task Scheduler states the same fact as Last Result: 3.
Why check this
A scheduled job runs when nobody is watching. The tester signing off a nightly export sees the next morning's data, by which time a failed run is eight hours old.
Run it on staging sign-off, after a deploy that changes a job's dependencies, and after a schedule change. It prevents this: a price sync fires on time, throws ECONNREFUSED 127.0.0.1:5432 on its first line, and counts as a run on every dashboard that counts runs.
A real system leaves a run row, a last-success timestamp, an exit code and an output file. This page reads all four, and keeps two facts apart: the scheduler fired, and the work completed.
Prerequisites
- Node 22. The scheduler uses node:sqlite, which prints an
ExperimentalWarningon start. - A SQLite client. The run below used
sqlite33.50.6. - Windows Task Scheduler for steps 7 and 8. See the schtasks reference.
- The target, saved as
scheduler.mjs. Three jobs on a 5 second interval:export-csvwrites a file,sync-pricesthrows,archive-logsreturns from a guard and writes nothing. Each job inserts a row on start and updates it on finish.
import { DatabaseSync } from 'node:sqlite';
import { createServer } from 'node:http';
import { writeFileSync, rmSync, mkdirSync } from 'node:fs';
const INTERVAL_MS = 5000;
rmSync('jobs.db', { force: true });
rmSync('out', { recursive: true, force: true });
mkdirSync('out');
const db = new DatabaseSync('jobs.db');
db.exec(`CREATE TABLE job_run (
id INTEGER PRIMARY KEY, job TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, status TEXT NOT NULL, detail TEXT)`);
const start = db.prepare("INSERT INTO job_run (job, started_at, status) VALUES (?, ?, 'running')");
const finish = db.prepare('UPDATE job_run SET finished_at = ?, status = ?, detail = ? WHERE id = ?');
const now = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
const jobs = {
'export-csv': () => writeFileSync('out/export.csv', `id,amount\n1,${Date.now()}\n`),
'sync-prices': () => { throw new Error('ECONNREFUSED 127.0.0.1:5432'); },
'archive-logs': () => { const pending = 0; if (pending === 0) return; writeFileSync('out/archive.tar', ''); },
};
function runAll() {
for (const [name, fn] of Object.entries(jobs)) {
const id = start.run(name, now()).lastInsertRowid;
try {
fn();
finish.run(now(), 'ok', null, id);
} catch (e) {
finish.run(now(), 'error', e.message, id);
}
}
}
setInterval(runAll, INTERVAL_MS);
runAll();
// The heartbeat a monitor reads: last successful finish per job, and its age.
createServer((req, res) => {
const rows = db.prepare(`SELECT job, MAX(finished_at) AS last_success FROM job_run
WHERE status = 'ok' GROUP BY job`).all();
const body = Object.keys(jobs).map((job) => {
const r = rows.find((x) => x.job === job);
const age = r ? Math.round((Date.now() - Date.parse(r.last_success + 'Z')) / 1000) : null;
return { job, last_success: r ? r.last_success : null, age_seconds: age, interval_seconds: INTERVAL_MS / 1000 };
});
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(body) + '\n');
}).listen(8931, '127.0.0.1', () => console.log(`scheduler on 8931, every ${INTERVAL_MS / 1000} s, pid ${process.pid}`));
- Two Windows tasks, plus one created and never started.
schtasks /create /tn h2check-sync /tr "cmd /c exit 3" /sc once /st 23:59 /f
Steps
- Step 1.
Start the scheduler and keep the pid it prints.
node scheduler.mjs(node:39848) ExperimentalWarning: SQLite is an experimental feature and might change at any time (Use `node --trace-warnings ...` to show where the warning was created) scheduler on 8931, every 5 s, pid 39848The pid stops it in step 9. A 5 second interval makes the page reproducible in two minutes.
- Step 2.
Ask the naive question first.
sqlite3 -header -column jobs.db "SELECT job, COUNT(*) AS started_recently, MAX(started_at) AS last_start FROM job_run WHERE started_at > datetime('now', '-15 seconds') GROUP BY job;"job started_recently last_start ------------ ---------------- ------------------- archive-logs 3 2026-09-12 18:06:24 export-csv 3 2026-09-12 18:06:24 sync-prices 3 2026-09-12 18:06:24Three jobs, three starts each. Nothing here is wrong and nothing is useful: it is the answer a green dashboard rests on. Both
datetime('now')andstarted_atare UTC. - Step 3.
Count successes, not runs.
sqlite3 -header -column jobs.db "SELECT job, COUNT(*) AS runs, SUM(status = 'ok') AS ok, MAX(CASE WHEN status = 'ok' THEN finished_at END) AS last_success, MAX(CASE WHEN status = 'error' THEN detail END) AS last_error FROM job_run GROUP BY job;"job runs ok last_success last_error ------------ ---- -- ------------------- --------------------------- archive-logs 8 8 2026-09-12 18:06:29 export-csv 8 8 2026-09-12 18:06:29 sync-prices 8 0 ECONNREFUSED 127.0.0.1:5432sync-pricesran eight times and succeeded zero times.runsbesideokis the smallest query that tells the two facts apart, andlast_errornames the dependency that is down. - Step 4.
Run the query a monitoring rule carries, and watch a job vanish.
sqlite3 -header -column jobs.db "SELECT job, MAX(finished_at) AS last_success FROM job_run WHERE status = 'ok' GROUP BY job;"job last_success ------------ ------------------- archive-logs 2026-09-12 18:06:34 export-csv 2026-09-12 18:06:34Two rows for three jobs. Filtering on success before grouping drops the job that never succeeded, so a staleness alert has nothing to fire against.
- Step 5.
Read the heartbeat the way a monitor does, over HTTP.
curl -s http://127.0.0.1:8931/jobs[{"job":"export-csv","last_success":"2026-09-12 18:06:34","age_seconds":5,"interval_seconds":5},{"job":"sync-prices","last_success":null,"age_seconds":null,"interval_seconds":5},{"job":"archive-logs","last_success":"2026-09-12 18:06:34","age_seconds":5,"interval_seconds":5}]This endpoint walks the list of jobs that should exist, so
sync-pricesreportsnullinstead of vanishing. Compareage_secondsagainstinterval_seconds: 5 against 5 is one interval behind, the freshest possible. - Step 6.
Check the artifact. An exit status is not an output file.
ls -l --time-style=full-iso out/total 1 -rw-r--r-- 1 khark 197121 26 2026-09-12 21:06:39.043345900 +0300 export.csvarchive-logsreportedokeight times and there is noarchive.tar. The file time carries+0300while the run rows are UTC, so an unconverted comparison calls a fresh file three hours stale. - Step 7.
Read the same fact from Windows Task Scheduler.
schtasks /query /tn h2check-sync /fo LIST /v… TaskName: \h2check-sync Next Run Time: 12.09.2026 23:59:00 Status: Ready … Last Run Time: 12.09.2026 21:03:44 Last Result: 3 … Task To Run: cmd /c exit 3 …Status: Readymeans the task is not running now, not that it worked.Last Run Timeis the scheduler firing;Last Resultis the exit code of what it fired. - Step 8.
List every task at once.
Get-ScheduledTask -TaskName 'h2check-*' | Get-ScheduledTaskInfo | Format-Table TaskName, LastRunTime, LastTaskResult -AutoSizeTaskName LastRunTime LastTaskResult -------- ----------- -------------- h2check-missing 12.09.2026 21:04:04 2147942402 h2check-archive 30.11.1999 0:00:00 267011 h2check-export 12.09.2026 21:05:05 0 h2check-sync 12.09.2026 21:03:03 3Four states in one table.
h2check-missingpoints at a path that does not exist, so a run is recorded and nothing ran. The two readers also disagree on the clock:21:03:03here against21:03:44in step 7. - Step 9.
Stop the scheduler by the pid from step 1, then ask again.
powershell -Command "Stop-Process -Id 39848 -Force" curl -s http://127.0.0.1:8931/jobs; echo "curl exit $?"curl exit 7Exit 7 is a refused connection. A monitor reading only the heartbeat body cannot tell a failing job from an absent scheduler. Stop by pid: a stop by image name takes every node on the machine.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| runs 8, ok 8 and a fresh last_success | The job fired and finished on every interval | Keep the pair in the check. One number cannot say this. |
| runs 8, ok 0 with a last_error | The schedule works and the job does not | Fix the dependency in last_error, here a database on 127.0.0.1:5432. |
| A job missing from the result entirely | The query filtered on success before grouping | Drive the query from the job list, as step 5 does. A missing row raises no alert. |
| status ok and no output file | The job returned early and reported success | Assert on the artifact and its mtime as well as the status. |
| Last Result: 267011 with a 1999 date | Windows has never started this task | Check the trigger, not the job. See Why is my cron job not running. |
| Last Run Time recent, Last Result non-zero | The scheduler fired and the program failed or was not found | Decode the code. 2147942402 is 0x80070002, file not found. |
| curl exit 7 on the heartbeat | Nothing is scheduling at all | Alert on the endpoint being absent, separately from what it reports. |
Common mistakes
What to check next
- Why is my cron job not running: no run row at all.
- How to check if a cron job is still running: a run that never released its slot.
- How to validate cron expression: the schedule you wrote against the one you meant.
- How to check if a queue worker is running: the same question for a worker that pulls.
- How to test graceful shutdown: a deploy during a run.
FAQ
How do I test a cron job without waiting for the schedule?
Trigger the entry on demand. schtasks /run /tn h2check-sync started the task here and set Last Result to 3 within four seconds. For a timer, shorten the interval: this run used 5 seconds.
How do I check that a cron job ran successfully, not only that it started?
Compare two numbers from one row. runs 8, ok 0 is a job the scheduler fired eight times that finished none of them. A check counting only starts calls it healthy.
How do I test a scheduled task on Windows?
Create it with schtasks /create, start it with schtasks /run, then read Last Run Time and Last Result with schtasks /query /fo LIST /v. Get-ScheduledTaskInfo reports both fields for several tasks at once.
Where do cron job logs live?
That depends on the daemon and the distribution, and it answers a different question. A log path says what the scheduler did; the run table says what your code did.
Verified
Verified by Maks Vernynode 22.23.2node:sqlite 3.51.3sqlite3 3.50.6curl 8.21.0PowerShell 5.1.22621.6133GNU coreutils 8.32
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
intermediate10 minpublished updated Maks Verny