Charlie KrugThe Build Log

← All posts

0 0 13 * 5 is not Friday the 13th. Blame cron's OR rule.

Cronlet is a tiny, dependency-free cron parser that turns any expression into plain English and back, and gets the one edge case (day-of-month OR day-of-week) that everyone gets wrong.

You want a job to run on Friday the 13th, so you reach for the obvious cron string: 0 0 13 * 5. Midnight, day-of-month 13, day-of-week 5 (Friday). Every field is correct. And it runs on the wrong days: every Friday, plus every 13th of the month, dozens of times a year instead of once or twice. The expression that looks like an AND is actually an OR, and cron will not tell you.

The rule almost nobody has memorized

When you set both the day-of-month and the day-of-week fields to something other than *, cron matches a day if either one matches. This is the Vixie cron behavior that every serious implementation inherited, and it is the single most misread corner of the syntax. 0 0 13 * 5 means "the 13th, OR any Friday," never "Friday the 13th." Get it wrong and your job's schedule silently changes; nothing errors, because nothing is invalid.

Cronlet is a small TypeScript library that parses, schedules, and describes cron expressions, and it exists partly to make that OR rule impossible to trip over. The core is about 150 lines, gzips to roughly 4.7 KB, and has zero runtime dependencies. It drops into a browser, a Cloudflare Worker, or a Node service without dragging a tree of transitive packages behind it.

How it gets the edge cases right

The trick to the OR rule is deciding it once, at parse time, instead of guessing later. When Cronlet parses an expression it records two flags, domRestricted and dowRestricted, marking whether each of those fields was narrowed from *. The scheduler reads those flags and applies the exact Vixie logic: if both are restricted, a day matches when day-of-month OR day-of-week matches; otherwise it's a plain AND across all five fields. The ambiguity lives in one place and is resolved once, so next() never has to re-derive intent from the raw string.

Computing the next run is deliberately boring, which is the point. Instead of re-implementing a calendar, next() advances a real Date object field by field and lets the platform handle month lengths, leap years, and daylight-saving jumps. All scheduling is in local wall-clock time, so "every day at 2:30am" behaves the way the host's timezone says it should, DST and all, rather than drifting because someone did calendar math by hand.

The third piece is describe(), which turns any expression into a plain-English sentence. It is not a lookup table of known strings; it reasons about the parsed field shapes, so it generalizes to expressions it has never seen. */15 9-17 * * 1-5 comes back as "at every 15th minute past hour 9 through 17, on Monday through Friday." A cron string in a config file stops being a write-only riddle.

Small, and I can prove it

Benchmarked against cron-parser (a solid reference that pulls in luxon), Cronlet parses about 5.7x faster and computes the next run about 20x faster, at roughly a quarter of the bundle size with no dependencies. Those numbers are from npm run bench on Node 18; yours will vary, but the shape holds because there simply isn't much code to run.

The companion page is the part worth clicking. Paste any cron string and it resolves live, no button press, into three things at once: a plain-English sentence, the next five run times, and a color-coded schematic of the five fields. Type into the English builder and it composes the cron string back. It's a two-way Rosetta Stone for cron, entirely client-side, and it's where the OR rule stops being a footnote and becomes something you can watch happen.

Try it

Open the live page and paste 0 0 13 * 5. Read the next five run times and notice they are not all Friday the 13th. Then try 30 9 * * 1-5 or @weekly and watch the English and the schematic update as you type. If you want it in your own code, npm install cronlet and the source is on GitHub.

Cronlet is live. Free, in your browser, no signup.

This post is part of the build log: every app my automated factory ships gets written up here, honestly. Browse everything at apps.charliekrug.com. Comments are open below.

Comments

Loading comments…