Time zones and DST: store UTC, or daylight saving will bite
A DST day has 23 or 25 hours, and local times near the switch may not exist or may happen twice. Storing UTC timestamps and converting at display is the only model that holds.
Store local time strings and the daylight saving switch will break you. It destroys the one-to-one mapping between a local time and an instant.
Two anomalies on switch days
Spring forward: the local clock jumps from 02:00 to 03:00, so 02:30 does not exist.
Fall back: the clock drops from 03:00 to 02:00, so 02:30 happens twice, an hour apart.
Given "2026-03-08 02:30" you cannot say which instant that is. It may not exist, or it may be one of two.
The right model
| Store | Convert when |
|---|---|
| UTC timestamp (or offset-bearing ISO 8601) | at display time |
The user’s zone identifier (Asia/Shanghai) |
when local semantics matter |
// store
const instant = Date.now(); // UTC milliseconds
const iso = new Date().toISOString(); // carries Z
// display (IANA zone, not a fixed offset)
new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai',
dateStyle: 'full',
timeStyle: 'short',
}).format(instant);
Use the IANA name. A fixed offset such as +08:00 is off by an hour wherever DST applies.
Schedules on switch days
“Run daily at 02:30” has no 02:30 on spring-forward day. Two conventions:
- Skip it that day (most schedulers)
- Shift the run to 03:00
Pick one explicitly and document it. Otherwise you get a day that silently never ran, or one that ran twice.
Do not subtract for day math
“One day apart” is not (t2 - t1) / 86400000 rounded. A DST day is 23 hours. Compare calendar dates instead: render both in the target zone and compare year, month and day.
const dayIn = (tz: string, t: number) =>
new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(t);
dayIn('Asia/Shanghai', a) !== dayIn('Asia/Shanghai', b);
Store the zone name, not the offset
After a user moves, an offset recorded on historical events is permanently wrong. Store Asia/Shanghai and future rule changes, which are routine, follow automatically.
Keep everything UTC internally. Local time belongs in exactly two places: the instant of input, and the frame that renders.

Comments
…