Time Zones and DST for Developers: What Actually Goes Wrong
Time zones cause more production bugs than almost any other single source of complexity. Not because they are mysterious, but because developers encounter three different things — Unix timestamps, UTC offsets, and named time zones — and treat them as interchangeable. They are not.
The three concepts
| Concept | Example | What it represents |
|---|---|---|
| Unix timestamp | 1719446400 | Seconds since 1970-01-01 00:00:00 UTC. Absolute, unambiguous, time-zone-free. |
| UTC offset | +05:30 | A fixed offset from UTC. Does NOT account for DST — it is just a number. |
| Named time zone | America/New_York | A political zone with a history of offsets and DST rules. The IANA database defines ~600 of them. |
The critical mistake: storing +05:30 when you mean Asia/Kolkata. An offset is a snapshot; a named zone is a policy that changes over time.
What DST actually does
Daylight Saving Time (DST) shifts clocks forward by one hour in spring and back in autumn. The effect on a fixed UTC offset:
America/New_Yorkis UTC−5 in winter (EST) and UTC−4 in summer (EDT)Europe/Londonis UTC+0 in winter (GMT) and UTC+1 in summer (BST)- About 40% of the world does not observe DST —
Asia/TokyoandAsia/Kolkataare always UTC+9 and UTC+5:30 respectively
The two DST edge cases that break code
Gap — spring forward (2:00 AM → 3:00 AM)
When clocks spring forward, times between 2:00 and 3:00 AM do not exist. Code that constructs a DateTime at 2:30 AM on that day is creating an invalid time. Different libraries handle this differently — some throw, some silently shift forward, some shift backward. Know what yours does.
Fold — fall back (2:00 AM → 1:00 AM)
When clocks fall back, times between 1:00 and 2:00 AM occur twice. 2026-11-01T01:30:00 in America/New_York is ambiguous — it could be EDT (UTC−4) or EST (UTC−5). A correct representation must specify which fold: 2026-11-01T01:30:00-04:00 (first occurrence) or 2026-11-01T01:30:00-05:00 (second).
Rule: store UTC, display local
The universal rule for any application that stores timestamps:
- Store all timestamps as UTC (or Unix timestamp integers)
- Convert to local only at display time, using the user's time zone
- Never store a local time without its time zone information
A timestamp stored as 2026-06-15 14:00:00 with no time zone is a ticking bug. Is that UTC? The server's local time? The user's local time? Nobody knows.
JavaScript: Temporal API vs Date
JavaScript's built-in Date object is notoriously bad at time zones — it only knows UTC and the local system time zone. For anything more complex, use a library or the new Temporal API (Stage 3, available in Node 22+ with the --experimental-vm-modules flag and via polyfill):
// Temporal API — the correct modern approach
import { Temporal } from "@js-temporal/polyfill";
const utcNow = Temporal.Now.instant();
// Convert to New York time
const nyTime = utcNow.toZonedDateTimeISO("America/New_York");
console.log(nyTime.toString());
// 2026-06-15T10:00:00-04:00[America/New_York]
// Convert to Kolkata time
const koTime = utcNow.toZonedDateTimeISO("Asia/Kolkata");
console.log(koTime.toString());
// 2026-06-15T19:30:00+05:30[Asia/Kolkata]
// Safe arithmetic — skips DST gaps automatically
const tomorrow = nyTime.add({ days: 1 });For production code today, use date-fns-tz or Luxon — both handle named time zones correctly using the IANA database.
PostgreSQL: timestamp vs timestamptz
PostgreSQL has two timestamp types:
timestamp(without time zone) — stores the value as-is with no zone awareness. Dangerous if your app and DB server are in different zones.timestamptz(with time zone) — always stored as UTC internally, converted to the session time zone on display. Always use this.
-- Set session time zone for queries
SET TIME ZONE 'America/New_York';
-- Always use timestamptz for application timestamps
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Convert for display
SELECT created_at AT TIME ZONE 'Asia/Kolkata' AS kolkata_time
FROM events;REST API: ISO 8601 with offset
Always include a time zone offset in API timestamps. 2026-06-15T14:00:00Z (the Z means UTC) is unambiguous. 2026-06-15T14:00:00 is not — clients may interpret it as local time, UTC, or refuse to parse it.
- Use
Zsuffix for UTC:2026-06-15T14:00:00Z - Use explicit offsets when storing user-local context:
2026-06-15T10:00:00-04:00 - Never return bare local times from an API
Common bugs and fixes
| Bug | Fix |
|---|---|
| Recurring event fires at wrong time after DST change | Schedule in named time zone, not UTC offset |
| Age/duration calculation is off by 1 hour | Compute in UTC; convert only for display |
| Dates jump when server time zone differs from user | Use timestamptz in Postgres; store UTC everywhere |
new Date("2026-06-15") returns previous day in US | Date-only strings are parsed as UTC midnight; add time component or use a library |
Try it yourself
Use the free browser-based Timezone Converter on DevBench — no signup, runs entirely in your browser.
Open Timezone Converter