The classic lines at the incident scene
"An API returned 14:30, but it displays 06:30 for another user" "All due dates in the week across DST came an hour early"—two complaints that say it all: the problem is never 'what o'clock', but the split between the absolute instant and its local representation.
Start from the one rule that cannot be bent.
Everything starts from UTC: the absolute instant
Every moment on Earth is unique in the universe. A Unix timestamp (seconds/milliseconds since 1970-01-01 00:00:00 UTC) gives it a location-independent number; an ISO8601 string with its offset gives it a self-describing textual form:
timestamp 1785891600000 (milliseconds)
ISO8601 2026-09-04T09:00:00Z (UTC)
with offset 2026-09-04T17:00:00+08:00 (17:00 in Beijing)
Z means UTC; +08:00 means eight hours ahead of UTC. All three point to the same instant—only "what the clock reads" differs.
Why DST is so anti-human
Daylight saving time is a twice-yearly rules change in many countries. In North America:
spring-forward 02:00 → 03:00 the hour 02:00-03:00 doesn't exist
fall-back 02:00 → 01:00 the hour 01:00-02:00 occurs twice
So: one day has only 23 hours, another has 25. Subtract two local calendar times to compute a duration, and that day is an hour off.
The typical mistake
// ❌ subtracting local time objects to compute a duration is an hour off across DST
const hours = (new Date(b).getTime() - new Date(a).getTime()) / 3600e3
getTime() returns absolute milliseconds and is fine; the mistake is that you built the objects from local time, which is already interpreted using the machine's zone—if a DST boundary is involved you can't avoid the error.
The right approach
// durations must always be based on absolute instants
const start = Date.parse('2026-03-07T00:00:00Z') // be explicit
const end = Date.parse('2026-03-08T00:00:00Z')
const realHours = (end - start) / 3600e3 // 26h? depends on those Z instants
The IANA timezone database and the fact that time zones change
Time zones are political decisions: countries adopt/drop DST, abandon it permanently, shift offsets, and change switch dates—which is why IANA (tzdata) updates so often. This means:
- Don't hardcode offsets, and don't embed DST rules in code;
- Use a maintained timezone library (standard libs / luxon / date-fns-tz per language) and upgrade on schedule;
- Return the IANA zone name (
Asia/Shanghai,America/New_York) plus the current offset in APIs so the frontend can render correctly.
Post-mortems of three real incidents
Incident 1: stored local time, lost the offset. A user saved 09:00 in Beijing; synced to a US device it was treated as local 09:00—N hours different. → Fix: always store UTC / offset-carrying ISO8601.
Incident 2: renewal duration across DST. Used "after 30 local calendar days" instead of "after 30 absolute days"; crossing DST, it was an hour short/long. → Time moments use absolute instants; "add 30 days" means 24h×30.
Incident 3: frontend rendered in the server's zone. The API returned 09:00 with no offset; the frontend rendered it in the server's zone, so overseas users saw the wrong time. → Backend gives absolute instants; frontend renders in the browser's local zone.
A one-glance self-check
store → UTC / epoch / ISO8601 with offset ✔
transfer → ISO8601 with offset or Z ✔
display → frontend local zone ✔
duration → always from absolute instants ✔
zone → IANA name + current offset ✔
Closing
Timestamps aren't hard; the difficulty is separating the physical instant from the wall-clock display. Remember "store with offset, transfer with offset, display in local, durations from absolute instants, zone by IANA name"—and you'll stop being the one who's 'always an hour off'.