DEV Community

gdf ty
gdf ty

Posted on

I Built a Cron Expression Parser From Scratch - Here's Every Edge Case That Almost Broke It

 Title: I Built a Cron Expression Parser From Scratch — Here's Every Edge Case That Almost Broke It


Cron expressions look simple until you try to parse one correctly.

0 9 * * 1-5 — "9 AM on weekdays" — is easy to read once you know cron syntax. But building something that turns any valid cron string into a correct, human-readable sentence (and predicts the next few run times) turns out to be a small minefield of edge cases. I recently built a free cron expression parser and visual builder as a side project, and along the way I ran into a handful of gotchas that I think are worth writing down — both for anyone building similar tooling, and for anyone who just wants to understand cron a little better.

The five fields (and the sixth one nobody agrees on)

Standard cron has five fields:

┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12)
│ │ │ │ ┌───────────── day of week (0–6, Sunday=0)
│ │ │ │ │
* * * * *
Enter fullscreen mode Exit fullscreen mode

Some systems (Quartz, some cron forks) prepend a seconds field, making it six. The safest way to handle both without forcing the user to pick a mode upfront is to just count the fields:

const parts = cronStr.trim().split(/\s+/);
if (parts.length === 6) {
  [sec, min, hour, dom, month, dow] = parts;
} else {
  [min, hour, dom, month, dow] = parts;
}
Enter fullscreen mode Exit fullscreen mode

Simple, but it's the first decision that shapes everything downstream.

The OR trap: day-of-month AND day-of-week

This is the one that trips up almost everyone the first time they hit it. You'd assume 0 9 15 * 1 means "9 AM on the 15th, and only if it's also a Monday." It doesn't. In standard cron, when both day-of-month and day-of-week are restricted (neither is *), the two conditions are combined with OR, not AND. That expression actually means "9 AM on the 15th of every month, OR every Monday" — a much broader schedule than most people expect.

let dayOk;
if (domIsWild && dowIsWild) {
  dayOk = true;
} else if (domIsWild) {
  dayOk = dowMatch(dow);
} else if (dowIsWild) {
  dayOk = domMatch(dom);
} else {
  // the surprising part
  dayOk = domMatch(dom) || dowMatch(dow);
}
Enter fullscreen mode Exit fullscreen mode

If you're building a cron tool, this is worth surfacing as an explicit warning whenever both fields are set — it's the single most common source of "why did my job run on a day I didn't expect" bug reports.

The silent-truncation trap: don't trust parseInt alone

This one was the most dangerous bug I found in my own field-matching code, and it's subtle enough that it's worth calling out on its own.

Quartz-style cron supports extensions like 5#3 (the 3rd Friday of the month) or 15W (the nearest weekday to the 15th). If your parser doesn't explicitly support these, the safe failure mode is to reject them outright. The dangerous failure mode — the one I initially shipped — is this:

const num = parseInt(part, 10);
if (isNaN(num) || num < minVal || num > maxVal) return null;
matches.add(num);
Enter fullscreen mode Exit fullscreen mode

parseInt("5#3", 10) doesn't return NaN. It returns 5, silently stopping at the first non-digit character. So a field meant to mean "only the 3rd Friday" gets quietly reinterpreted as "every Friday" — no error, no warning, just a schedule that's wrong in a way nobody would notice until a job ran far more often than intended. Same story for 15W: parseInt("15W", 10) returns 15, so "nearest weekday to the 15th" silently becomes "exactly the 15th."

The fix is to validate that the entire token is numeric before accepting it, not just its numeric prefix:

if (!/^\d+$/.test(part)) return null; // reject instead of silently truncating
const num = parseInt(part, 10);
Enter fullscreen mode Exit fullscreen mode

Lesson: if you're going to support a subset of cron syntax, make unsupported syntax fail loudly. A parser that's wrong silently is far more dangerous than one that's wrong loudly.

Predicting "next run" without a scheduling library

For a human-readable preview ("this will next run on Mon Aug 3, Tue Aug 4, ..."), you don't need a full scheduling engine — brute-force works fine for a UI feature:

let current = new Date();
current.setSeconds(0, 0);
current.setMinutes(current.getMinutes() + 1);

while (results.length < count && iterations < SAFETY_LIMIT) {
  if (minMatch(m) && hourMatch(h) && monthMatch(mon) && dayOk) {
    results.push(new Date(current));
  }
  current.setMinutes(current.getMinutes() + 1);
  iterations++;
}
Enter fullscreen mode Exit fullscreen mode

The catch is choosing SAFETY_LIMIT. A cap of "one year of minutes" (525,600) feels generous, but it's not enough for genuinely valid, if rare, schedules — 0 0 29 2 * (Feb 29th only) can be up to four years away depending on where you are in the leap-year cycle. A one-year cap will confidently report "no matching executions found," which reads like a bug even though the cron expression is completely valid. If you're building something similar, either raise the cap for known-rare patterns or make the empty-state message explicitly say "none found within the next year" rather than implying the expression itself is broken.

Names vs. numbers: parse them, but explain them too

Cron supports named months and weekdays (MON-FRI, JAN,FEB) as aliases for their numeric equivalents. It's easy to normalize these for matching purposes:

function normalizeCronField(fieldStr, mapObj) {
  let s = fieldStr.toUpperCase();
  for (const [key, val] of Object.entries(mapObj)) {
    s = s.replace(new RegExp(key, 'g'), val);
  }
  return s;
}
Enter fullscreen mode Exit fullscreen mode

But if your tool also generates a human-readable explanation, that explanation logic needs to run against the same normalized values — otherwise you end up with an inconsistency where 1-5 renders as "Monday through Friday" but the functionally identical MON-FRI renders as the literal, un-expanded string "MON-FRI." Both should produce the same sentence. It's a good reminder to keep a single source of truth for field interpretation, rather than parsing the same field twice with two different code paths.

Wrapping up

None of these are exotic problems — they're the kind of thing you only find by throwing weird-but-valid inputs at your own code. If you're working on anything that touches cron (or writing one yourself as a learning exercise), I'd genuinely recommend testing against: six-field vs five-field input, the DOM/DOW OR behavior, Quartz extensions your parser doesn't support, and at least one leap-year-only expression.

I turned mine into a small free tool if you want to see the parser in action or just need to decode a cron expression someone handed you without the trial and error: Cron Expression Generator & Parser — paste an expression and it explains it in plain English, flags the DOM/DOW OR gotcha automatically, and shows the next few run times. It's fully client-side, so nothing you paste in ever leaves your browser.


Have you run into other cron gotchas? I'd love to hear about them in the comments — I'm sure there are edge cases I haven't hit yet.

Top comments (0)