For years I copied cron expressions off Stack Overflow, pasted them into a config file, crossed my fingers, and moved on. 0 9 * * 1-5? Sure, that "looks like weekday morning." */15 * * * *? "Every 15 minutes, probably." I never actually read them. So I did the thing that always cures this for me: I built a tool that parses a cron expression, explains it in plain English, and shows the next five times it will fire. No library. About 50 lines of real logic. Here's everything I learned.
The five fields (and the order that trips everyone up)
A standard cron expression is exactly five fields separated by spaces:
┌──────── minute 0-59
│ ┌────── hour 0-23
│ │ ┌──── day-of-month 1-31
│ │ │ ┌── month 1-12
│ │ │ │ ┌ day-of-week 0-6 (0 = Sunday)
* * * * *
The order never changes, and the number-one beginner mistake is swapping the first two. Minute comes first. If you write 9 30 * * * thinking "9:30am," you actually get "minute 9, hour 30" — which is invalid, because hours only go to 23. Say it out loud every time: minute, hour, day-of-month, month, day-of-week.
Each field answers one question: which values of this unit does the job run on? An * means "every value." Most real schedules pin down a couple of fields and leave the rest as *. Daily at 9am is 0 9 * * * — minute and hour fixed, everything else "every."
Lists, ranges, and steps
Beyond single numbers, each field understands three operators, and they combine:
-
Comma makes a list:
1,15in the day field means the 1st and the 15th. -
Hyphen makes an inclusive range:
1-5in the day-of-week field means Monday through Friday. -
Slash makes a step, taking every n-th value:
*/15in the minute field means0, 15, 30, 45.
Steps can apply to a range too, so 0-30/10 means 0, 10, 20, 30. That's the whole grammar. Number, list, range, step. Once you can expand a field into the concrete set of numbers it matches, you understand cron.
Here's the expansion function, which is the heart of the parser:
function expandField(field, lo, hi) {
const out = new Set();
for (const part of field.split(",")) { // lists: a,b,c
let [rangePart, stepStr] = part.split("/"); // steps: x/n
const step = stepStr ? parseInt(stepStr, 10) : 1;
let start = lo, end = hi;
if (rangePart !== "*") {
const bits = rangePart.split("-"); // ranges: a-b
start = parseInt(bits[0], 10);
end = bits[1] !== undefined ? parseInt(bits[1], 10) : start;
}
for (let v = start; v <= end; v += step) out.add(v);
}
return out; // "*/15" over 0..59 → {0,15,30,45}
}
Feed it */15 and the minute range 0..59 and you get exactly {0, 15, 30, 45}. That set is what you test each minute against.
Names, and Sunday's identity crisis
Two fields accept three-letter names for readability. The month field takes JAN through DEC, and the day-of-week field takes SUN through SAT. So MON-FRI is identical to 1-5, and JAN,JUL is identical to 1,7. A parser just maps each name to its number before expanding.
There's one quirk in the day-of-week field: it officially runs 0–6 with 0 = Sunday, but by long convention many implementations also accept 7 as Sunday. So both 0 and 7 point to the same day. The clean fix is to normalise 7 down to 0 while parsing, so internally there's only one value for Sunday.
The gotcha that has bitten every developer: day-of-month OR day-of-week
This one genuinely surprised me. You'd expect all five fields to be AND'd — the job runs only when every field matches. That's true for minute, hour, and month. But the two day fields are special: if you restrict both day-of-month and day-of-week, cron matches when either one matches. They're OR'd, not AND'd.
So 0 0 1 * MON does not mean "midnight on the 1st, but only if it's a Monday." It means "midnight on the 1st of the month, OR on any Monday." If one of the two day fields is *, only the other one applies and there's no surprise.
function dayMatches(date, sets) {
const domStar = sets.domRaw === "*";
const dowStar = sets.dowRaw === "*";
const domHit = sets.dayOfMonth.has(date.getDate());
const dowHit = sets.dayOfWeek.has(date.getDay()); // 0 = Sunday
if (domStar && dowStar) return true;
if (domStar) return dowHit; // only day-of-week is set
if (dowStar) return domHit; // only day-of-month is set
return domHit || dowHit; // both set → OR them
}
Whenever you see an expression that pins both day fields, stop and double-check what it really means.
Computing "next run" the honest way
I assumed finding the next run time needed clever arithmetic. It doesn't. The simplest correct approach is brute force, and it mirrors what a real cron daemon conceptually does: start at the next whole minute and step forward one minute at a time, testing each minute against all five fields until you get a hit. Want the next five runs? Keep going until you've collected five.
function nextRuns(sets, count) {
const out = [];
const d = new Date();
d.setSeconds(0, 0);
d.setMinutes(d.getMinutes() + 1); // start at the next minute
const cap = 366 * 4 * 24 * 60; // 4 years of minutes, a safety net
for (let i = 0; i < cap && out.length < count; i++) {
if (matches(d, sets)) out.push(new Date(d));
d.setMinutes(d.getMinutes() + 1); // Date rolls hours/days/years for you
}
return out;
}
Minute-stepping can't miss an edge case the way clever arithmetic might. The one thing you must add is a safety cap, so an impossible schedule like the 30th of February (* * 30 2 *) doesn't loop forever. Four years of minutes is more than enough to be sure — if nothing matched in that window, the schedule is unsatisfiable.
The bug that produces silently-wrong schedules
JavaScript's Date.getMonth() returns 0 for January and 11 for December. Cron's month field is 1 for January and 12 for December. If you compare them directly, every month is off by one and your job fires in the wrong month — with no error, no crash, just wrong. Always add one:
if (!sets.month.has(date.getMonth() + 1)) return false;
Conveniently, getDay() (0–6) and getDate() (1–31) already line up with cron, so month is the only field that needs the fix. But it's a great reminder to check what range your date library uses before trusting a comparison.
A couple of last things
Cron expressions have no time zone of their own — they run in whatever zone the machine or service is set to. 0 9 * * * fires at 9am server time, which may not be your 9am. Many cloud schedulers now let you attach a zone; my tool just shows the next runs in your browser's local zone so what you see matches your own clock.
And classic five-field cron has no seconds field: one minute is the finest granularity. Some systems bolt on an optional sixth field for seconds, but that's a non-standard extension.
That's the whole thing. Split into five fields, expand each into a set of numbers, respect the OR day rule, and step forward minute by minute to find the next runs. Once you've built it once, you never squint at a cron line again.
Try the interactive version — type any expression and watch it explain itself and predict its next five runs: https://dev48v.infy.uk/solve/day21-cron-explainer.html
United States
NORTH AMERICA
Related News

Master Local Fine-Tuning with "gemma-trainer"
8h ago
Building a Plugin-Free Newsletter Popup on WordPress: Custom REST Endpoint Mailchimp API v3
8h ago
ภาษาโปรแกรมมิ่งที่ syntax ง่าย ทำให้ AI หลอนน้อยลง จริงหรือ?
8h ago
How I Built a File-Timestamp-Based Feedback Loop to Enforce AI Output Quality
9h ago
GitHub Trending Digest — 2026-07-07
9h ago