Building ToolRunner·9 min read

Cron Expressions, Timezones, and the Lies Previews Tell

Building ToolRunner's Cron Generator: two-way builder sync, where cron-parser and cronstrue disagree, and why next-run previews need an explicit IANA timezone.

by Dowon Oh

Cron looks like a solved problem. Five fields, a man page from the 1970s, and every backend engineer can recite */5 * * * * in their sleep. Then you sit down to build a cron expression tool — one that parses expressions, describes them in English, and predicts the next ten run times — and you discover that "cron" is not one language. It is a family of dialects that mostly agree, and the places where they disagree are exactly the places where a preview can look authoritative while being wrong. This article is a tour of the three hard parts I hit while building ToolRunner's Cron Generator: keeping a text expression and a visual builder in sync without infinite loops, reconciling a validator and a describer that speak slightly different dialects, and refusing to show a next-run time until the user has committed to a real IANA timezone. I have been burned by "server time" enough times in production — Kubernetes CronJobs firing at 9 AM UTC when the team meant 9 AM Seoul — that the last one is personal.

Two sources of truth, one expression

The Cron Generator has two ways to edit the same schedule: a raw text input where you type 0 9 * * 1-5 directly, and a visual builder with one selector per field, each offering four modes — every value, specific values, a range, or a step. Both must stay in sync, and each one writing to the other is the classic recipe for an update loop.

The state model that made this tractable: the expression string is the canonical value, and the builder state is a projection of it. Two pure functions convert between them. decomposeExpression splits the string on whitespace and parses each token into a field state (* or ? means "every", a/b means step, a-b means range, comma lists mean specific values). composeExpression does the reverse, mapping each field state back to a token. A ref tracks which side wrote last — text input or builder — so a change flows exactly one direction per edit and never echoes back.

One design decision saved a pile of edge cases: decomposeExpression always returns exactly six field states, even for a five-field expression. Standard crontab has five fields — minute, hour, day of month, month, day of week, in that order per the crontab(5) man page — but Spring and several other schedulers prepend a seconds field. Rather than juggling arrays of two different lengths, the builder state is always six elements with seconds at index 0; a five-field expression just leaves seconds at its default. When the user toggles between five- and six-field mode, the tool recomposes from the same array, either including index 0 or slicing it off. The toggle becomes a rendering decision instead of a data migration.

The honest limitation: the builder's four editable modes cover *, lists, ranges, and steps — which is the overwhelming majority of real-world expressions — but cron's exotic tokens (L for last day, # for "second Tuesday", named days like MON-FRI) have no visual UI. An early version of the decomposer quietly coerced them — parseInt reads 5#2 as plain 5, which would have turned "second Friday" into "every Friday" the moment you touched the builder. That class of silent rewrite is exactly what a cron tool must never do, so these tokens now decompose into a locked "advanced syntax" state: the builder displays the token verbatim, refuses to edit it, and recomposes it unchanged. Validation and previews work on the full expression either way; the text input is the escape hatch for syntax that most users will never type.

The validator and the describer speak different dialects

Here is the part that genuinely surprised me. The tool uses two libraries for two jobs: cron-parser to validate expressions and compute run times, and cronstrue to translate them into English. Both are excellent. They do not implement the same language.

Validation is a thin wrapper that converts cron-parser's exceptions into a result object, because a tool that throws on every keystroke of a half-typed expression is unusable:

export function validate(expr: string): ValidationResult {
  try {
    CronExpressionParser.parse(expr);
    return { valid: true };
  } catch (err) {
    return { valid: false, error: (err as Error).message };
  }
}

Type 61 * * * * and you get { valid: false, error: 'Constraint error, got value 61 expected range 0-59' } — a real message the UI can show, not just a red border. The description side runs cronstrue with throwExceptionOnParseError: false, so instead of throwing on garbage it returns a fallback string, and the page only renders a description when validation already passed.

That last clause is doing more work than it looks like, because the two libraries disagree at the edges:

  • Nonstandard macros. cron-parser accepts @weekdays and @weekends as predefined expressions (aliases for 0 0 0 * * 1-5 and 0 0 0 * * 0,6). cronstrue has no idea what they are and returns its parse-error fallback. So an expression can be perfectly valid in the tool while having no English description. No crontab implementation I know of accepts these macros either — paste @weekdays into a real crontab and cron will reject the line.
  • Field-count ceilings. cronstrue happily describes Quartz-style seven-field expressions with a trailing year — 0 0 12 * * ? 2027 becomes "At 12:00 PM, only in 2027." cron-parser rejects the same string with "Invalid cron expression, too many fields." Since validation gates everything in my tool, seven-field Quartz expressions are simply invalid there, and I decided that is correct for a tool whose snippet targets are crontab, GitHub Actions, Kubernetes, and Spring — none of which take a year field.
  • The 5/6-field ambiguity. */5 * * * * is "every 5 minutes." Add one more asterisk — */5 * * * * * — and both libraries silently reinterpret the whole string: the first field is now seconds, and your job runs every 5 seconds. Both libraries agree here, which is almost worse, because nothing flags that a stray field changed the schedule's frequency by a factor of 60. This is why the tool has an explicit 5/6-field toggle instead of guessing.

The lesson generalizes past cron: any time one library validates input and a different library explains it, the gap between their grammars becomes a place where your UI can display something confidently wrong. Gate the describer behind the validator, and test the corners where the dialects diverge.

A walkthrough: the "and" that is actually an "or"

Take this expression, which looks like it schedules something for Friday the 13th:

0 0 13 * 5

Field by field: minute 0, hour 0, day of month 13, month *, day of week 5 (Friday). cronstrue describes it as:

At 12:00 AM, on day 13 of the month, and on Friday

Read that sentence and you would bet the job fires only when the 13th falls on a Friday. Now look at what cron-parser — and every Vixie-descended cron on a real server — actually computes for the next runs starting August 2026 (UTC):

2026-08-07  Friday
2026-08-13  Thursday
2026-08-14  Friday
2026-08-21  Friday
2026-08-28  Friday
2026-09-04  Friday

Every Friday, plus the 13th of every month. The crontab(5) man page is explicit about this rule: when both day-of-month and day-of-week are restricted (neither is *), the command runs when either field matches. The English description says "and"; the scheduler executes "or." That is the single most misleading sentence a cron tool can show, and it is not cronstrue's fault — there is no natural English rendering of "day-of-month OR day-of-week but AND with everything else" that fits on one line.

The cron-parser README is refreshingly honest about this ambiguity, noting that the library "allows both parameters to be set by default, although the resultant behavior might not align with your expectations," and offers a strict mode that rejects such expressions outright. For the Cron Generator I kept strict mode off — rejecting expressions that real crontabs accept would make the tool less useful as a parser — and leaned on the next-runs preview instead. A description can mislead; a list of ten concrete timestamps cannot. If you see three Fridays and a Thursday in the preview, the "or" semantics are staring right at you. This is why the preview is not a nice-to-have in a cron tool. It is the only output that tells the truth unconditionally.

"Server time" is not a timezone

Every next-run preview has to answer a question most cron tools dodge: midnight where? An expression has no timezone. 0 0 * * * means midnight in whatever zone the evaluating daemon happens to run in — which is how a "daily report at midnight" cron on a UTC server delivers reports at 9 AM Seoul time, and how the same manifest behaves differently in two clusters. The Kubernetes CronJob documentation makes the failure mode explicit: if you do not set spec.timeZone, the schedule is interpreted in the timezone of the kube-controller-manager. Not your laptop, not your users, not even necessarily the node your pod lands on — the control plane process. GitHub Actions is blunter still: on.schedule cron runs in UTC, no option offered.

So the Cron Generator refuses to pretend. nextRuns takes an explicit IANA timezone name and passes it straight to cron-parser, which handles DST transitions internally:

export function nextRuns(expr: string, tz: string, count: number): Date[] {
  const interval = CronExpressionParser.parse(expr, {
    tz,
    currentDate: new Date(),
  });
  return interval.take(count).map((d) => d.toDate());
}

The UI defaults the selector to your own zone via Intl.DateTimeFormat().resolvedOptions().timeZone and fills the dropdown from Intl.supportedValuesOf('timeZone') — the browser's own IANA database, no bundled timezone list to drift out of date. Each of the ten upcoming runs is then formatted in that zone with dayjs's timezone plugin, as YYYY-MM-DD HH:mm:ss (ddd). The day-of-week suffix is there because "did I mean Monday in UTC or Monday here?" is exactly the class of bug this preview exists to catch.

DST is where an explicit zone stops being pedantry. Schedule 30 2 * * * in America/New_York and on 2026-03-08 the clock jumps from 02:00 straight to 03:00 — 2:30 AM does not exist that day. cron-parser shifts that run to 3:30 AM EDT rather than dropping it, and because the preview renders real computed instants instead of naively formatting "02:30", you can see the anomaly in the list before it pages you at a weird hour. If you need to sanity-check what a given instant looks like across zones — say, confirming that 09:00 in Seoul is midnight UTC before you write the crontab for a UTC-pinned runner — the Timezone Converter covers the other half of that workflow.

One more dialect trap lives in the snippet generator. The tool emits ready-to-paste blocks for crontab, GitHub Actions, Kubernetes CronJob manifests, and Spring's @Scheduled — and the Spring snippet silently prepends 0 to five-field expressions, because Spring's cron requires six fields with leading seconds. Paste a five-field crontab expression into @Scheduled unmodified and Spring throws at startup; the fields shift by one and your "hour" becomes a "minute." The snippet doing that translation for you is a tiny feature, but it encodes the entire thesis of this article: the same five characters mean different things to different schedulers, and tooling should absorb that difference instead of letting you discover it in a stack trace.

Paste your ugliest expression

The Cron Generator runs entirely in your browser — expression, timezone, and field-format choices live in the URL query string, so a schedule you have debugged is a link you can drop in a code review. Start from one of the presets (every 5 minutes, hourly, daily at midnight, Mondays, weekdays at 9 AM), or paste the crustiest expression in your legacy crontab and watch the next ten runs render in an explicit timezone. If the English description and the timestamp list seem to disagree, trust the timestamps. The description is a courtesy. The preview is the contract.

Sources