← Back to Blog

Timezone & DST Pitfalls: Why Your Timestamps Are Always an Hour Off

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'.

Frequently Asked Questions

Should I store UTC or local time?

Store and transmit in **UTC (or ISO8601 with its explicit offset); convert to local only for display**. Absolute moments are physical and objective, but 'what time is it' depends on the observer's zone and changes with travel and DST. Storing local time without the offset makes one row read as different moments in different zones; storing UTC converts correctly everywhere regardless of DST. Practice: use `TIMESTAMP WITH TIME ZONE` / epoch in databases and return ISO8601 with `+08:00` or `Z` from APIs.

Why do some duration calculations gain or lose an hour around DST?

**DST makes a day 23 or 25 hours long**. In the spring-forward transition the local hour 02:00-03:00 doesn't exist; in fall-back the hour 01:00-02:00 occurs twice. If you compute elapsed time by subtracting two local calendar times, that day ends up an hour off. Correct: convert both endpoints to absolute moments (instant/UTC) and subtract, or be explicit about physical duration—a day is 24h, a week 168h, and only a 'day' spanning DST is not 24h.

Why must display include a timezone rather than just numbers?

Because the same 14:30 points to different absolute instants in different zones; bare numbers lose the meaning. ISO8601 standard is to suffix the offset: `2026-09-04T14:30:00+08:00` (or `Z` for UTC). Any parser can then recover the instant and render it in the audience's local zone. **Best practice: the backend hands out absolute instants (with offset) and the frontend renders using the browser's local zone—servers never know where the user is and shouldn't decide what clock to show.**

Why do some countries'/regions' time zones change?

Time zones aren't physics; they're **politics and legislation**. Countries shift offsets, adopt/permanently-abandon DST, or change switch dates (Turkey, Egypt, Mexico and others did recently)—which is why the IANA `tzdata` database ships updates frequently. Consequence: hardcoded offsets (e.g. `+08:00`) or embedded DST rules rot over time; always use a maintained timezone library with updates, and have APIs return the up-to-date IANA zone name (e.g. `Asia/Shanghai`, `America/New_York`) along with the current offset for display.

← Back to Blog