Reliable HTTP cron and delayed-job scheduling with automatic retries and full run logs — delay.run
This is a hypothetical case study. The company, numbers and timeline are invented to illustrate a real class of bug. If you've built anything that repeats monthly, you may recognise it anyway.
The setup
A small SaaS company sells a monthly plan. When a customer subscribes, the system stores a next_renewal_at timestamp. Every hour, a job looks for subscriptions whose next_renewal_at is in the past, charges them, and sets the next renewal:
sub.nextRenewalAt = addMonths(sub.nextRenewalAt, 1);
addMonths comes from a popular date library. It does the sensible thing when the target month is shorter: it clamps to the last day of that month. January 31 plus one month is February 28 (or 29).
Tests pass. Renewals go out. For about a year, nobody thinks about it again.
The symptom
The first sign isn't a bug report. It's a support ticket with a mild tone: "I signed up on the 31st, why am I now always charged on the 28th?"
Then a few more, mostly from customers who'd signed up on the 29th, 30th or 31st. One says their company's expense process expects the charge at the end of the month and now it lands before their budget resets. Another says the charge is "random". It isn't random, but from the outside it looks it.
What actually happened
Walk through one subscription that started on January 31:
| Renewal | Computed from | Result |
|---|---|---|
| 1 | Jan 31 + 1 month | Feb 28 |
| 2 | Feb 28 + 1 month | Mar 28 |
| 3 | Mar 28 + 1 month | Apr 28 |
| 4 | Apr 28 + 1 month | May 28 |
The clamp in February was correct. The problem is what came after: each renewal was computed from the previous renewal, not from the original signup date. Once a date has been clamped to the 28th, the information that the customer "really" renews on the 31st is gone. Every later month inherits the clamp.
A customer who started on March 31 drifts at the first short month, April, to the 30th, then stays on the 30th, then drops to the 28th the following February and stays there for good.
In other words, the system had a one-way ratchet. Billing days could move earlier, never later.
A calendar quirk is easier to catch when every renewal attempt is logged. delay.run runs the HTTP job on the schedule you set and records each attempt, so a drift shows up in the history.
Why nobody caught it
A few reasons, all ordinary:
- Tests checked one step. The test suite verified that Jan 31 + 1 month = Feb 28. It never checked the twelfth renewal.
- Most customers were unaffected. Anyone who signed up between the 1st and the 28th never drifts. Only customers on the last three days of the month are exposed, and only after a short month.
-
The data looked fine. Every individual
next_renewal_atwas a valid date a month after the last one. No row was "wrong" on its own. - Support tickets didn't mention dates precisely. "Always charged early" doesn't sound like a date arithmetic bug until someone lines up the history.
The fix: store the anchor, compute from it
The fix is a change of model, not of library. Instead of storing only "when is the next renewal", the system stores the billing anchor: the original day of month (and time, and time zone) the subscription is meant to renew on. Every renewal is computed from the anchor and a counter, not from the previous renewal.
// anchor: { year: 2026, month: 0, day: 31 } (Jan 31)
function renewalDate(anchor, n) {
const y = anchor.year + Math.floor((anchor.month + n) / 12);
const m = (anchor.month + n) % 12;
const lastDay = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
return new Date(Date.UTC(y, m, Math.min(anchor.day, lastDay)));
}
Now the sequence for a January 31 anchor is Feb 28, Mar 31, Apr 30, May 31. The clamp happens only in the months that need it, and the customer returns to the 31st whenever the month allows.
(In a real system you'd also handle the anchor's time of day and time zone; this snippet uses UTC days to keep the idea visible.)
The migration nobody enjoys
Fixing future renewals was easy. Repairing existing subscriptions was the interesting part, because the original anchor had been overwritten for anyone who'd drifted.
In this hypothetical, the team:
- Recovered anchors from history. The first successful charge or the subscription's creation date gave the original day of month for almost everyone.
- Decided not to "jump" anyone suddenly. Moving a customer from the 28th back to the 31st means one billing period is three days longer. The team chose to apply the corrected anchor at the next renewal and to let the slightly longer period stand without extra charge, rather than charging a prorated amount.
- Emailed affected customers with a short, plain note: what had happened, what would change, and that nobody would pay more.
- Added a property test that generates random anchors and checks 36 consecutive renewals against an independent calculation.
Lessons that generalise beyond billing
Recurring schedules need an anchor. Whether it's billing, reports, reminders or payroll, "every month on the 31st" is a rule. "The next one is on the 28th" is just one result of that rule. Store the rule, and derive the results.
Never compute the nth occurrence from the (n-1)th when any step can lose information. Clamping, rounding, time zone shifts and daylight saving changes all lose information.
Test sequences, not steps. Date bugs often hide in the composition of individually correct operations. Generate a year or three of occurrences and compare them with expectations.
Decide the business rule explicitly. "Last day of the month if the day doesn't exist" is one reasonable rule. "First day of the following month" is another. Some businesses avoid the problem by only allowing anchors from the 1st to the 28th. Any of these is fine, as long as it's chosen on purpose and written down.
Getting recurring "later" right, so that the twelfth occurrence is as correct as the first, is the kind of problem we think about all day at delay.run.
Related reading
- 'Remind me next Tuesday at 3': 6 libraries for parsing natural-language dates, and the traps in each
- Cron job timezone problems: how to run cron in a specific time zone (and survive DST)
- Case study: redesigning a failed-payment retry schedule that retried every card at 00:00 UTC
- Quick tip: one Postgres advisory lock stops your scheduled job running twice
How does your code compute the next occurrence of something monthly: from the last one, or from an anchor?
Top comments (0)