I’ve been working on WealthRelay, an API venture I built from the ashes of my old MarketRacoon code, pivoting what used to be a consumer portfolio tracker into a B2B solution. Somewhere along the way I had to admit something uncomfortable: I was using cron wrong. Not “wrong flag” wrong - conceptually wrong.

The tell: fortnightly

The feature that forced the realization was recurring schedules: automated deposits, loan repayments, interest runs. And the single most requested shape for those is the one banks use for mortgages: fortnightly.

Fortnightly sounds trivial until you try to write it down precisely:

  • Every second Friday: 52 weeks / 2 = 26 payments a year
  • The 1st and 3rd Friday of each month: 12 × 2 = 24 payments a year

These are not the same schedule. Banks care a lot about the difference - the 26-payment version is the classic trick for paying a mortgage off years early, because you sneak in the equivalent of one extra monthly payment per year. Any scheduling system that can’t tell these two apart is unusable for lending.

Cron can express the second one, awkwardly (0 0 1-7,15-21 * FRI - “a Friday falling in the first or third week-ish of the month”). It cannot express the first one. At all.

Why cron can’t - and why that’s by design

A cron expression is a stateless predicate. Give it a timestamp and it answers one question: does this instant match? Minute, hour, day-of-month, month, day-of-week - five field filters, evaluated against the clock, with no memory and no anchor. That statelessness is cron’s superpower: the daemon can crash, restart, skip a beat, and nothing needs to be reconciled because the schedule carries no history.

“Every second Friday” breaks the model completely. Which Fridays are the even ones? You can only answer relative to an anchor - some epoch that decides the parity. The question “does 2026-07-17 match?” is no longer answerable from the timestamp alone.

My first instinct was to bolt it on anyway. Some cron dialects support a % modulo extension (0 0 * * FRI%2), so I extended the parser and anchored week parity to a fixed epoch Monday. It worked, in the way hacks work. But the anchor was invisible global state: every %2 schedule in the system shared the same phase. Two customers wanting alternating fortnights? Impossible. Migrating the epoch? Every schedule shifts by a week. And no other cron implementation on earth would parse my strings the same way - or at all.

That’s the point where I stopped blaming cron. Fifty years of cron implementations - Vixie, Quartz, Cronos, all of them - and none ever grew a portable “every N weeks”. Quartz added L, W and # for calendar arithmetic, Jenkins added H for load spreading, but nobody added interval-weeks, because it cannot be done without an anchor, and an anchor is state, and state is precisely what cron promises not to have. What I was computing - interest accruals between payment dates, next-deposit stepping, schedules that terminate after N payments - was about as far from stateless as it gets. Wrong tool, correctly refusing to do the job.

RRULE: the thing that was built for this

The fix already existed, and it’s older than my career: RFC 5545 recurrence rules, the RRULE in every calendar invite you’ve ever received.

DTSTART:20260109T000000Z
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR

Every second Friday, anchored. The anchor problem that broke cron is the first field of the format: DTSTART is part of the rule, per schedule, explicit. The two fortnightly variants that cron couldn’t distinguish are both one-liners:

RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=FR      # 26 a year
RRULE:FREQ=MONTHLY;BYDAY=FR;BYSETPOS=1,3   # 24 a year

And everything else on the wishlist turned out to be built in: COUNT=120 for a loan with exactly 120 repayments, UNTIL for end dates, BYMONTHDAY=-1 for “last day of the month”. It’s a plain string, so it serializes into a database column exactly like the cron expression it replaced. Mature evaluators exist in every stack (Ical.Net on .NET, rrule.js in the browser, python-dateutil…). The migration deleted my modulo hack, an intervalDays side-channel column, and the mutual-exclusion validation between them, and replaced all of it with one rrule column.

One footgun worth knowing: RFC 5545 says an impossible instance is skipped, not clamped. BYMONTHDAY=31 silently never fires in February. If you mean “end of month”, say BYMONTHDAY=-1 - I now reject anything above 28 at validation time and point people at negative offsets instead.

To be clear, cron keeps its job for what it’s actually for: my system jobs (imports, price syncs) are still Quartz cron triggers, because “at 3am every day” is a stateless question. The rule of thumb I landed on: cron describes when the system wakes up; RRULE describes when something in the domain is due.

The frontend problem, and the LLM shortcut

The one thing cron had going for it was familiarity, and even that never translated into good UI - the state of the art is crontab.guru, which decodes expressions rather than helping you build one, and I remember struggling to get any cron input component to do what I wanted years ago.

RRULE is no better off. The pickers that exist are welded to a specific UI kit (the best React one depends on MUI), which is a non-starter if your design system is anything else.

This is where the economics have genuinely changed. When the open-source option doesn’t fit, the cost of building exactly what you want has collapsed - with an LLM doing the mechanical parts, the gap between “close enough, I’ll live with the dependency” and “built to spec” is an afternoon. So instead of adopting a component I’d fight forever, I made the base of the form I wanted and open-sourced it:

react-rrule-form (live demo) - a recurrence form for React with rrule.js as the engine. Daily/weekly/monthly/yearly, intervals (fortnightly is two clicks), weekday pills, day-of-month grid with a proper “last day” cell, “2nd Monday” style rules, count/until endings, a live natural-language summary and an upcoming-occurrences preview. Styling is slot-based: Tailwind defaults you can override per element (conflicts resolved with tailwind-merge), an unstyled mode when you want none of my opinions, and a precompiled stylesheet if you don’t use Tailwind at all.

It ships from a boring, satisfying pipeline: Storybook deployed to GitHub Pages from the repo itself, and an npm publish (with provenance) that triggers on version tags and refuses to run if the tag doesn’t match package.json.

If you’re storing cron strings for anything a human would describe with a calendar - payments, invoices, reminders, anything with “every other” or “the last X of” in it - you’re one unsupported requirement away from the modulo hack. Skip that step. The RFC has been waiting since 1998.