Why Cron Parsing Is Harder Than It Looks
Cron expressions look simple—a handful of fields that describe a repeating schedule. But the format has never been standardized, so every parser tends to support its own subset of extensions. That becomes a real problem when a product like Cron Triggers on Cloudflare Workers has a frontend, an API, and an edge service written in different languages, because each piece of the stack may interpret the same expression differently.
The core format is straightforward: each field can hold a star (*) for all values, a single value (numeric or a three-letter abbreviation for months or weekdays like JUN or FRI), a range like 0-30, or a set of ranges and values like 0-15,30,45-50,55. But real-world schedulers need more:
Lin the day-of-month position means the last day of the month; in the day-of-week position with a value like7L, it means the last Saturday of the month.Wspecifies the closest weekday to a given day, e.g.15Wmeans the closest weekday to the 15th./adds step values:*/5in the minute field means every fifth minute, and ranges can be combined like30-59/5.#in the day-of-week field selects the nth occurrence, such as5#3for the third Thursday of the month.
Additional extensions exist in other tools (like Jenkins' H), and none of these are guaranteed to be present in any given library. For Cron Triggers, the Cloudflare team decided which extensions mattered and which didn't—seconds were too granular and years too broad, so both were dropped.
Four Parsers, Four Sets of Rules
Building Cron Triggers meant picking a cron library for each layer of the stack. The backend was written in Rust, and no existing crate supported the desired feature set in a way that exposed a simple "does this time match?" API, so the team wrote their own parser called saffron, built with the nom parser combinator crate. Adding extensions was straightforward because the compiler caught safety issues, leaving only logic testing—though edge cases like last-offset weekdays (L-XW) and leap years took extra effort.
The frontend had different requirements—it didn't need to match specific times, but it did need to describe cron expressions in plain language and show the next five execution times. With a tight deadline, two existing JavaScript libraries were pulled in, one for descriptions and one for future-time calculation. Both performed their own parsing, which meant they also acted as validators.
That split caused real problems. The two JavaScript libraries supported different extensions than the Rust backend, so the UI sometimes accepted expressions the API would reject, and in other cases rejected valid expressions the backend would accept. The API itself used a Go library for validation that was more permissive than the actual schedule runner, meaning triggers could be created but silently ignored at runtime. Before launch, four different parsers were in play, each with slightly different rules.
Band-Aids Before Launch
To keep inconsistencies from reaching users at release, two fixes were applied. First, the API and backend were synced by adding an entry point to the schedule runner that read a cron expression from standard input, parsed it, and reported validity. Second, the API gained a validation endpoint that the UI could call to confirm the backend would accept an expression.
These worked but were far from elegant. Every UI validation meant parsing the expression twice in JavaScript and then making a round trip to the API, which spun up an instance of the schedule runner just to check the parse. Worse, the UI was limited to the intersection of features supported by both JavaScript libraries. Since one of them lacked L and W support, users couldn't create schedules using those extensions even though the backend handled them fine.
An Off-by-One Bug in Weekday Numbers
After launch, another issue surfaced. Saffron was modeled on the Quartz scheduler, where weekday numbers run from 1 (Sunday) to 7 (Saturday). Both frontend parsers followed the original cron convention of 0 (Sunday) to 6 (Saturday), with 7 also allowed for Sunday. The result: when a user entered 1-5, the UI described the schedule as Monday through Friday, but the backend executed Sunday through Thursday.
Fixing this was tricky. The description library offered a configuration option to switch between the two numbering schemes, but the future-times library did not. By then, work had already begun on replacing that library with saffron, but WebAssembly bindings weren't ready and there wasn't time to write them. A quicker path was needed.
Cloudflare Workers as the Glue
The existing Rust code was deployed as a Cloudflare Worker using WebAssembly entry points. Since Workers run close to users, validation no longer required a round trip to a centralized API—a Worker at a nearby point of presence could handle the parsing. The validation logic stayed in Rust with the wasm bindings, while the request and response handling was written in JavaScript.
After about a week of work, the UI was fixed. Replacing the old future-times parser with saffron also implicitly added support for extensions the previous library lacked. Still, two parsers remain in production: the description library and saffron. The team is aware of at least one expression—0 0 L-1W 2 *, meaning "12:00 AM on the closest weekday to the second-to-last day of February"—that the backend accepts, but the description library cannot parse, so users can schedule it without receiving a plain-language description.
Working Toward One Parser
The goal is to reduce the stack to a single parser used everywhere. Rust's ecosystem makes this feasible across all three layers:
- Frontend: saffron can be compiled to wasm with
wasm-packand integrated into the existing webpack setup, providing both future-time calculation and descriptions locally in the browser. - API: because Rust can expose a C-compatible API, the parser can be linked into Go code using
cgo, eliminating the need to launch the schedule runner just for validation.
Once descriptions are also handled by saffron, the second frontend parser can be removed. The result would be a single source of truth wrapped for different languages rather than multiple independently written parsers, eliminating the possibility of cross-layer inconsistencies.
Now It's Open Source
Cloudflare has released saffron as an open source project on GitHub at https://github.com/cloudflare/saffron. The project demonstrates how Rust's type system and safety properties make the parser easy to port and bind to other languages. Published packages for bindings are planned on npm and crates.io, and development is public for those who want to follow along or contribute improvements.



