How to Handle DST Transitions in Recurring Calendar Events
Recurring calendar events have a DST problem that only appears twice a year — and almost always in production, not in tests.
A user creates a recurring meeting: “9am every Tuesday, America/New_York.” In winter, 9am EST = 14:00 UTC. In summer, 9am EDT = 13:00 UTC. The UTC time changes; the local time stays the same.
If you stored 14:00 UTC as the recurring time instead of 09:00 America/New_York, every Tuesday in summer the meeting fires at 10am New York time. The user notices in March, wonders why their recurring meeting “moved,” and files a ticket.
The root cause
There are two different things a recurring time can mean:
- Wall clock time: “This meeting is always at 9am in my city.” The UTC equivalent changes with DST.
- Fixed UTC time: “This meeting always fires at 14:00 UTC.” The local time changes with DST.
For user-facing scheduling — meetings, reminders, standups — the user means wall clock time. If you store a fixed UTC offset, you’re implementing the other thing, which is almost never what they want.
The correct storage model
Store two things:
- The local time and recurrence rule (
RRULE):DTSTART;TZID=America/New_York:20260106T090000/RRULE:FREQ=WEEKLY;BYDAY=TU - The IANA time zone name:
America/New_York
When generating the next occurrence, convert local time to UTC at the moment of calculation — not at the moment of creation. This ensures post-DST occurrences use the correct (summer) offset.
Don’t store a fixed UTC offset (+05:30, -05:00) as the zone for a recurring event. Offsets are snapshots. IANA names are rules.
iCalendar (RFC 5545) gets this right
The iCalendar spec uses TZID on DTSTART for exactly this reason:
BEGIN:VEVENT
DTSTART;TZID=America/New_York:20260106T090000
RRULE:FREQ=WEEKLY;BYDAY=TU
SUMMARY:Weekly standup
END:VEVENT
When a calendar client expands this recurrence after DST, it computes each occurrence in America/New_York — so the June occurrences are generated as 13:00 UTC and the January occurrences as 14:00 UTC, both correctly mapping to 9am local.
If you’re building a scheduling tool, follow this model.
Generating occurrences in code
JavaScript (with Temporal API):
import { Temporal } from "@js-temporal/polyfill";
const timeZone = "America/New_York";
const startLocal = Temporal.PlainDateTime.from("2026-01-06T09:00:00");
// Generate next 8 Tuesday occurrences
const occurrences = [];
let current = startLocal;
for (let i = 0; i < 8; i++) {
const zonedDT = current.toZonedDateTime(timeZone);
occurrences.push(zonedDT.toInstant().toString()); // UTC instant
current = current.add({ weeks: 1 });
}
// January occurrence → 2026-01-06T14:00:00Z (EST, UTC-5)
// June occurrence → 2026-06-02T13:00:00Z (EDT, UTC-4) ✓
Python:
from datetime import datetime
from zoneinfo import ZoneInfo
from dateutil.rrule import rrule, WEEKLY, TU
tz = ZoneInfo('America/New_York')
start = datetime(2026, 1, 6, 9, 0, tzinfo=tz)
occurrences = list(rrule(WEEKLY, byweekday=TU, dtstart=start, count=8))
for dt in occurrences:
print(dt.astimezone(ZoneInfo('UTC')))
# Each occurrence is computed in America/New_York — DST handled automatically
Testing DST handling
Write explicit tests around DST transition dates. For America/New_York:
- Spring forward: second Sunday of March (clocks jump from 2am to 3am)
- Fall back: first Sunday of November (clocks fall from 2am to 1am)
Test that a recurring event set for 9am on the Tuesday before and after each transition fires at 9am local time both weeks — not 9am and then 10am.
// Test: Tuesday before spring forward → 14:00 UTC
// Test: Tuesday after spring forward → 13:00 UTC
// Both should display as 09:00 America/New_York ✓
This test is cheap to write and covers the entire class of recurring event DST bugs.