Fetching latest headlines…
I finally understood cron expressions by building an explainer for them
NORTH AMERICA
🇺🇸 United StatesJuly 1, 2026

I finally understood cron expressions by building an explainer for them

1 views0 likes0 comments
Originally published byDev.to

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,15 in the day field means the 1st and the 15th.
  • Hyphen makes an inclusive range: 1-5 in the day-of-week field means Monday through Friday.
  • Slash makes a step, taking every n-th value: */15 in the minute field means 0, 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

Comments (0)

Sign in to join the discussion

Be the first to comment!