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
Top comments (1)
How did you handle handling edge cases like daylight saving time in your explainer, I'm curious to know your approach. Would love to swap ideas on this.