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'.
Reproducible Results
Why fixed offsets cannot express a time zone — New York's offset changes within a year:
from datetime import datetime
from zoneinfo import ZoneInfo
ny_jul = datetime(2025, 7, 1, 12, 0, tzinfo=ZoneInfo('America/New_York'))
ny_jan = datetime(2025, 1, 1, 12, 0, tzinfo=ZoneInfo('America/New_York'))
print(ny_jul.utcoffset()) # → -1 day, 20:00:00 (UTC-4, daylight time)
print(ny_jan.utcoffset()) # → -1 day, 19:00:00 (UTC-5, standard time)
Same IANA zone name, one hour apart between July and January — the entire cause of "the same timestamp converts differently in winter and summer". Store UTC, convert at display time, use IANA names (America/New_York) rather than fixed offsets, and let a rule-table library do the conversion. More DST edge cases in the body; verify interactively with the timezone converter.