Writing a Cron Parser and Multi-Timezone Next-Run Calculator in ~300 Lines of Vanilla JS
A small, zero-dependency static web tool, and the two things that turned out to be actually interesting about building it.
Every time I hand a cron expression to a teammate in another timezone, I do the same mental math. "0 9 * * 1-5 in JST — that's 00:00 UTC, which for PT is… 16:00 the previous day during standard time, 17:00 during DST… hold on, is DST active right now?"
crontab.guru is a wonderful tool, but it only shows one timezone at a time. So I built the thing I wanted: a static page that shows the next 10 runs of any cron expression across UTC / JST / PST / EST / CET in one table, side by side, DST-aware.
🔗 Live demo: https://sen.ltd/portfolio/cron-tz-viewer/
📦 GitHub: https://github.com/sen-ltd/cron-tz-viewer
The whole thing is plain HTML/CSS/JS with no build step and zero runtime dependencies. Total surface area: about 500 lines including tests. The two parts I want to write about are the timezone handling and the algorithm for computing next runs without hanging the browser on @yearly input.
Part 1: You don't need a timezone library anymore
For years, "timezone math in JavaScript" meant moment-timezone or luxon. Both are excellent. Both are also 100+ KB of payload for a tool whose entire UI is a table.
It turns out modern browsers ship a complete IANA timezone database inside Intl.DateTimeFormat. Here's the entire timezone-aware "what's the wall clock right now in Tokyo" function:
const DOW_MAP = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
export function wallClock(utcMs, tz) {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: 'numeric',
weekday: 'short',
hourCycle: 'h23',
});
const parts = fmt.formatToParts(new Date(utcMs));
const get = (type) => parts.find((p) => p.type === type).value;
return {
year: Number(get('year')),
month: Number(get('month')),
day: Number(get('day')),
hour: Number(get('hour')),
minute: Number(get('minute')),
dow: DOW_MAP[get('weekday')],
};
}
A few things make this work:
-
formatToPartsreturns a typed array of{ type, value }instead of a locale-formatted string, so you never have to parse "Dec 25, 2026" back apart. -
hourCycle: 'h23'forces 24-hour output — no more worrying about whetherhour: 'numeric'returned12 AMor00. -
Any IANA zone just works, including DST transitions.
Asia/Tokyo,America/Los_Angeles,Europe/Berlin— all handled by the browser's built-in database.
The rest of the tool formats a single UTC timestamp into all 5 display timezones just by calling this function 5 times. No library. No DST tables I have to keep updated.
Part 2: Don't loop minute-by-minute
My first pass at nextRuns(ast, tz, from, count) looked like this:
// Naive: works, but hangs the browser on @yearly.
while (results.length < count) {
const wc = wallClock(t, tz);
if (matches(ast, wc)) results.push(t);
t += MINUTE;
}
It worked great for */5 * * * *. Then I typed @yearly into the input. 525,600 loop iterations per match × 10 matches = browser frozen for several seconds. Not shippable.
The fix is to skip forward whenever a mismatch tells you that an entire larger unit can't possibly contain a match:
while (results.length < count && i++ < MAX_ITERATIONS) {
const wc = wallClock(t, sourceTz);
if (!ast.month.has(wc.month)) {
t += DAY - (wc.hour * HOUR + wc.minute * MINUTE);
continue;
}
if (!dayMatches(ast, wc.day, wc.dow)) {
t += DAY - (wc.hour * HOUR + wc.minute * MINUTE);
continue;
}
if (!ast.hour.has(wc.hour)) {
t += HOUR - (wc.minute * MINUTE);
continue;
}
if (!ast.minute.has(wc.minute)) {
t += MINUTE;
continue;
}
results.push(t);
t += MINUTE;
}
The idea: if the hour doesn't match, there's no point trying the next minute within this hour — they all have the same hour. Jump to the start of the next hour and try again.
The bug that cost me an hour
The first version of that algorithm did something simpler-looking: t += HOUR on an hour mismatch. Seems reasonable! But I wrote a test that failed:
test('0 0 * * * in JST → JST midnight', () => {
const runs = runsFor('0 0 * * *', 'Asia/Tokyo', ANCHOR_UTC, 2);
assert.equal(formatInTz(runs[0], 'Asia/Tokyo'), '2026-01-02 00:00');
});
Expected the first next run to be 2026-01-02 00:00 JST. Got 2026-01-03 00:00 JST. One full day late.
Walking through the algorithm by hand: start at 00:01 UTC = 09:01 JST. Minute is 1, not 0 → step minute. Loop to 10:00 JST. Hour is 10, not 0 → t += HOUR → 11:00 JST… continue → eventually 00:00 JST next day. Should match. So why the extra day?
Because the hour-skip didn't reset the minute, and so after the first minute-stepping loop ran from 09:01 → 10:00, the subsequent hour skips each kept the minute at 00 — fine. But the next iteration of the top-level loop landed me past the minute I cared about. On the second match, starting from 00:01 JST (the minute after the pushed match), the minute stepping ran from 00:01 → 01:00, then hour stepping ran all the way around the clock to 00:00 the day after.
The fix, which I showed above, is to subtract the current minute before adding an hour:
t += HOUR - (wc.minute * MINUTE);
If wc.minute is 23, we add 37 minutes instead of 60. We land exactly on the top of the next hour, where the minute is now 0, and the match logic can keep going from there. Same idea for day skipping: subtract the current hour*HOUR + minute*MINUTE so we land on 00:00 of the next day.
With that change, @yearly returns 10 next runs in ~2 ms. 0 0 29 2 * (Feb 29, leap-year only) returns 10 in ~40 ms — worst realistic case.
I found this bug through a test I wrote because I was vaguely suspicious of the DST-naive hour math. The lesson wasn't really "unit tests save you" — it was more that specifically testing the boundary between units is where cron-like algorithms go wrong.
What's in the repo
-
src/parser.js— cron string → AST. Pure function, DOM-free. -
src/nextRuns.js— AST + timezone + start instant → array of UTC epoch-ms. -
src/explain.js— AST → human-readable field breakdown (ja/en). -
src/main.js— DOM wiring, URL query sync, language toggle. No logic. -
tests/*.test.js— 36 cases running onnode --test, zero test deps. -
infrastructure/cloudformation/— S3 + CloudFront + Route53 templates. -
.github/workflows/deploy.yml— OIDC deploy on push to main.
No bundler, no linter config, no Prettier, no framework. npm test runs the tests. python3 -m http.server serves the site.
Why this project exists
This is entry #1 in a 100+ portfolio series by SEN LLC. We build small, focused, public things to demonstrate what we actually do. If you find this useful, the repo has an MIT license — fork, hack, ship.
Feedback, bug reports, and corrections welcome. Especially if you've got a nasty cron expression that breaks the parser — I'd like to know.