Learn

Architecture & pattern guides

Read the idea, build it on a Grid, prove it in a challenge. Short, practical guides — illustrated with RodGrid's own nodes — on system design, design patterns, flow modeling, and how to get the most out of the app.

All articles

Time zones, DST & recurring events

Why a weekly 10:00 meeting is a local time in a named zone, not a UTC timestamp: instants vs wall-clock intent, IANA zones as rule sets that change by law, the DST gap and overlap, storing an RRULE and expanding it per window, editing one occurrence vs the series, and computing the next reminder.

15:00 UTC, plus seven days

start: 2027-01-07T15:00:00Z, repeat: every 7 days. That is the record a calendar service keeps for a weekly call its organiser booked in New York for Thursdays at 10:00, with attendees in London and São Paulo. The first ten Thursdays land at 10:00. From 18 March to 4 November 2027, every one lands at 11:00.

Nothing in the record changed. On Sunday 14 March, New York moved its clocks forward for daylight saving time (DST), from UTC−5 to UTC−4, and 15:00 UTC now reads 11:00 there. The service stored a moment correctly. What it never stored was what the organiser meant.

A moment, or a time on someone's clock

An instant is a point on the one global timeline, usually written in UTC. It is the right record for something that happened, such as a login or a message sent. The call is something else, a local time intent: 10:00 on Thursdays, as clocks in New York read. So that is what gets stored, the local date and time, the zone and the repeat rule, and instants are computed from them whenever someone needs one.

Computed that way, the call sits at 15:00 UTC until 14 March and at 14:00 UTC after it, always 10:00 in New York. London sees 15:00, then 14:00 for two Thursdays, then 15:00 again once its own clocks move forward on 28 March. São Paulo, which has no DST, sees 12:00 and then 11:00 until New York's clocks go back on 7 November. Those shifts are correct: the meeting follows New York's clock, so the zone belongs to the event, and each attendee's app only converts the instant for display.

Names, not offsets

Storing "10:00 −05:00" would carry the same bug. That is an offset, the difference from UTC at one moment. What the event needs is a time zone: the rules that decide which offset applies when, history included. Systems name zones from the IANA time zone database (the tz database): America/New_York, Europe/London, America/Sao_Paulo.

The rules are law, and law changes. Brazil abolished DST in 2019, months before its clocks would next have moved in November, and tz release 2019b shipped the change that July; a server or phone with older data still put São Paulo at UTC−2. An event stored as zone and local time is right once the data is updated. Future instants computed earlier stay wrong until recomputed. Past instants are facts: keep those in UTC.

02:30, which never happens

The same company runs a support desk whose overnight crew hands over every day at 02:30, New York time. On 14 March 2027:

  1. At 06:59:59 UTC, New York's clocks read 01:59:59, UTC−5.
  2. One second later they read 03:00:00, UTC−4.
  3. No instant that day has a local reading of 02:30.

That is the gap, and the system must pick a policy for it. RFC 5545, the iCalendar standard, interprets a nonexistent local time with the offset before the gap: 02:30 at UTC−5 is 07:30 UTC, which the clocks show as 03:30. Java's java.time and JavaScript's Temporal do the same by default. Yet the same RFC says that when a recurrence rule generates a nonexistent local time, that occurrence is ignored. Expanded strictly by the rule, the handover has no occurrence on 14 March, and no reminder. Decide which behaviour the desk wants, here probably 03:30, write it down, and test it against that date.

On 7 November 2027 the opposite happens. The clocks reach 01:59:59 at UTC−4 and go back to 01:00 at UTC−5, so every local time from 01:00 to 01:59 happens twice, an hour apart. That is the overlap: a handover at 01:30 would match both 05:30 and 06:30 UTC. RFC 5545 picks the first. Whatever you pick, pick one, or a check for "what is due at 01:30 local" runs it twice.

Store the rule, expand a window

In iCalendar terms, the weekly call is a start, DTSTART;TZID=America/New_York:20270107T100000, and a recurrence rule, RRULE:FREQ=WEEKLY;BYDAY=TH. The RRULE has no COUNT and no UNTIL, so the series never ends.

Writing every occurrence as a row has no last row. Any horizon, say two years, needs a job to extend it, and every series edit or tz database update rewrites hundreds of rows. The usual design stores the rule and expands it on demand: the month view asks for occurrences between two instants, and the expander walks the rule in New York local time, converts each occurrence to an instant with the current rules, and returns the ones inside the window.

The walk has to happen in the event's zone. Walking it in UTC is exactly the opening bug, and it breaks days as well as hours: a Thursday 20:00 meeting in Los Angeles is 03:00 or 04:00 on Friday in UTC, depending on the season, so BYDAY=TH expanded in UTC produces Thursdays in UTC, which are Wednesday evenings in Los Angeles.

Rule in, instants out

The events service expands the stored rule for the requested window in the event's zone, and hands the scheduler only the next reminder instant.

One Thursday, or all of them

The organiser cancels the call on 25 March. The series gains an exception, EXDATE;TZID=America/New_York:20270325T100000, and the expander drops that occurrence; in RFC 5545, exclusions win over everything the rule generates.

Then she moves 1 April to 14:00. That is an override: a separate record with the series' identifier (UID), its own new start, and the occurrence's original start as RECURRENCE-ID;TZID=America/New_York:20270401T100000. The expander generates 1 April at 10:00, finds the override by that original start and returns it instead. The window query must also fetch overrides moved into the window from outside it, and drop occurrences moved out. The original start is the key because it stays the same however often the occurrence moves.

That key is also how a series edit can corrupt the calendar. She moves the whole series to 11:00. The rule now generates 11:00 starts, the override's 10:00 RECURRENCE-ID matches nothing, and depending on the code, 1 April shows both the moved call and a regenerated 11:00 one, or the move is lost. The cancelled 25 March comes back the same way. A change to the series' time has to rewrite its exceptions in the same transaction, or ask the user which to keep.

"This and following" is usually built as a split: the old series gets an UNTIL before the chosen date, and a new series starts on it. The exceptions that fall after the split need the same rewrite-or-ask choice.

The next reminder is one instant

Each attendee wants a reminder 15 minutes before the call. The service stores, per event and attendee, one next fire instant: an occurrence's start minus 15 minutes, the first such time still after now. For 18 March that is 13:45 UTC. When it fires, the service expands the rule once more and stores the following one, and because it is an instant, the 01:30 handover on 7 November gets one reminder time, not two.

It is a derived value, so it is recomputed when its inputs change: an edit to the series or an exception, a tz database update, and, for a reminder set on the attendee's own clock, such as a London attendee's daily agenda at 07:00, a change of that attendee's zone. A scheduler then fires it on time, with the missed-run handling of Scheduled jobs & cron, or a workflow engine holds it as a durable timer (Durable workflows: orchestration that survives restarts); the email or push it triggers goes through Notifications & transactional messaging.

Days that are not instants

Not every calendar time names a moment. A public holiday on 1 May is a date: stored as 00:00 UTC, it starts on the evening of 30 April in New York, so iCalendar stores it as a plain date. A medication reminder at 08:00 "wherever I am" is a floating time, a local time with no zone at all. Ask of each time which kind it is before choosing a column for it.

Which Thursday survives?

Answer that on a Calendar & Scheduling Grid, whose brief asks for exactly this article's three pressures: recurrence across zones and DST, edits that do not corrupt the series, and reminders at local time. Book the weekly 10:00 in New York, cancel 25 March, move 1 April, then move the whole series to 11:00, and say in your design which records change and what each attendee sees on both dates. Add the 02:30 handover and state its 14 March policy. The brief's room booking is a different problem, concurrent writes to one slot, which Transactions & isolation levels handles; nothing about time zones prevents a double booking.

Practice this