Skip to content
3 min read

Every timezone bug is really a calendar bug

DST transitions, half-hour offsets, and the one assumption that breaks recurring meetings twice a year.

Scheduling bugs cluster around two moments in the year, and they are the same two moments for everyone: the second Sunday in March and the last Sunday in October — give or take, depending on which hemisphere and which government.

The assumption that breaks

The bug is almost always this: storing a meeting as a wall-clock time plus an offset rather than as an instant plus a timezone.

{ "startsAt": "2026-10-24T09:00:00+01:00", "recurrence": "WEEKLY" }

That looks unambiguous, and for a single meeting it is. But the recurrence rule has to answer “when is the next one?” — and on 25 October, +01:00 stops being the correct offset for Europe/London. Naively adding seven days gives you 2026-10-31T09:00:00+01:00, an instant that renders as 8:00am to the host. Their weekly standup silently moves an hour earlier, and only for the half of the team in a country that just changed its clocks.

The fix is to store the zone, not the offset:

{ "startsAt": "2026-10-24T09:00:00", "timeZone": "Europe/London", "recurrence": "WEEKLY" }

Now “next week at 9am” is a question about local time, resolved against the IANA database at read time, and it stays 9am through the transition.

The three rules we hold to

Store instants in UTC; store intent in IANA zones. A booked meeting has both: the UTC instant it occurs, and the zone the host expressed it in. You need the first to sort and the second to explain.

Never persist an offset as a zone. +05:30 is not Asia/Kolkata. It’s a fact about one moment that happens to be true for that zone right now. Offsets are output, never storage.

Resolve availability in the host’s zone, render in the guest’s. A host who says “I’m free 9–5” means their 9–5. Compute the window there, convert to instants, then present those instants wherever the guest happens to be. Doing it the other way — converting the rule itself — is how you end up with availability that shifts by an hour twice a year.

Watch out

Some zones have offsets that aren't whole hours — Asia/Kolkata at +05:30, Australia/Eucla at +08:45, Asia/Kathmandu at +05:45. If your slot generator steps in whole hours from midnight UTC, none of those users will ever see a slot on a round local time.

The nonexistent hour

The last one catches almost everybody. When clocks spring forward, a local time simply does not exist:

In America/New_York on 8 March 2026, the instant 02:30 never occurs — the clock jumps 01:59:59 → 03:00:00. If a host has a recurring 2:30am slot, or more plausibly if your code does date arithmetic that lands there, converting that local time to an instant is undefined. Different libraries resolve it differently: some throw, some shift forward an hour, some shift back.

And in autumn the inverse happens — 01:30 occurs twice, an hour apart, and “which one?” is a real question with two correct answers.

BunnyCal resolves nonexistent times forward and ambiguous times to the first occurrence, and — more importantly — never generates a bookable slot inside either window. A slot nobody can reliably attend isn’t worth the edge case.

Testing this without waiting for October

Freeze the clock. Every timezone test we have pins a specific instant and a specific zone, and the DST ones pin instants either side of a transition that has already happened. You do not need to wait for the calendar to test the calendar.