Skip to content
yutils

Timezone Pitfalls in JavaScript — UTC, IANA, DST, and Storing Dates Right

Why JS Date is dangerous, why you should always store UTC, the difference between offset and IANA name, daylight saving traps, and the right way to format for users.

~9 min read

Date and time bugs never end. "Notification fired an hour early." "Cron ran twice during DST." "Daily reports look different per user." This guide walks through JavaScript's Date traps, why you must store UTC, the difference between IANA names and offsets, and the daylight saving incidents that follow.

The biggest lie — that Date knows about timezones

Common misconception: a JS Date object carries timezone information. It doesn't.

  • A Date stores exactly one thing — UTC epoch milliseconds (ms since 1970-01-01 00:00 UTC).
  • Display methods like toString(), getHours(), getDay() convert to the OS timezone of the runtime.
  • The same new Date() on a server and in a browser shares an epoch value but produces different getHours() results.

Conclusion: Date doesn't carry "what region" — only the absolute instant. Pick a timezone at display time.

Always store UTC

Recommended DB column types:

  • Postgres: TIMESTAMPTZ (timezone aware). Inputs are normalized to UTC. Avoid TIMESTAMP WITHOUT TIME ZONE — you don't know which zone it represents.
  • MySQL: DATETIME with explicit UTC, or TIMESTAMP (auto-UTC, but watch the 2038 limit).
  • MongoDB: ISODate is always UTC.
  • Logs and messages: ISO 8601 with Z or +00:00. e.g. 2026-05-16T14:30:00Z.

Unix epoch (seconds or ms) is also UTC by definition — the most portable form. Unix Timestamp Converter converts both ways instantly.

Why not store the user's timezone?

What if the user travels and their timezone changes? Old data suddenly slides by an hour. In DST regions, the same wall-clock time becomes a different absolute instant in summer vs winter. UTC is the only reliable basis for sorting, comparing, and aggregating.

IANA name vs UTC offset

Two ways to denote a timezone.

IANA name (preferred)

Asia/Seoul, America/New_York, Europe/London, Pacific/Auckland. Includes every historical and future rule for the region (DST start/end, offset changes).

  • Pro: DST is automatic. Political changes ship via OS/browser updates.
  • Example: America/New_York automatically switches between EST (-5) and EDT (-4).

UTC offset (limited)

+09:00, -05:00 — fixed offsets. No DST knowledge.

  • Korea (KST = +09:00) has no DST, so the IANA name and the offset are interchangeable.
  • For DST regions, -05:00 alone loses the summer/winter distinction. Don't rely on it.
  • Useful only for absolute log timestamps.

Detect the user's timezone in the browser:

Intl.DateTimeFormat().resolvedOptions().timeZone
// "Asia/Seoul"

Cross-region time comparisons land in Time Zone Converter — IANA datalist with 400+ regions.

What DST breaks

1. The same wall time happens twice or never

At fall-back (e.g. US, first Sunday of November, 02:00 → 01:00), 01:30 happens twice. At spring-forward (March), 02:30 doesn't exist at all.

Effects:

  • A cron at 02:30 may not fire in spring and may fire twice in fall.
  • Notifications scheduled for "user-local 02:30" misfire on DST boundaries.

Fix: schedule cron in UTC; only the display is in the user's timezone.

2. 60 minutes ≠ 1 day / 24 hours

DST regions have 23- or 25-hour days. date + 24h may not land at the same wall time the next day. Compute day deltas after converting to UTC. Tools like Date Calculator handle this for you.

3. What "midnight" means

"Today's midnight" is meaningful for a user, but UTC midnight may already be 9 a.m. for them (Seoul). Daily aggregations purely by UTC slide by a day from the user's perspective. Convert the user's local midnight to UTC for query bounds.

Display — formatting for users

Intl.DateTimeFormat is fast, standard, and feature-rich.

new Intl.DateTimeFormat("en-US", {
  timeZone: "America/New_York",
  dateStyle: "long",
  timeStyle: "short",
}).format(new Date()) // "May 16, 2026 at 10:30 AM"

For custom patterns (YYYY-MM-DD HH:mm), Date Formatter renders moment-style tokens immediately.

For "just now" / "3 hours ago", Intl.RelativeTimeFormat:

const rtf = new Intl.RelativeTimeFormat("en", {numeric: "auto"});
rtf.format(-3, "hour"); // "3 hours ago"

API contracts — ISO 8601 + Z

Use ISO 8601 UTC for client/server payloads.

  • Response: "created_at": "2026-05-16T14:30:00Z"
  • Request: same — UTC ISO. The server interprets it as UTC unconditionally.
  • +09:00-style offsets are valid ISO. If you need the IANA name, send it as a separate field, e.g. {"due_at": "2026-05-16T14:30:00Z", "due_tz": "Asia/Seoul"}.

Cron and schedulers

  • Schedule cron in UTC. Immune to OS timezone changes.
  • When showing the cron to the user, translate at display time — 0 9 * * * means UTC 09:00 but you display "Asia/Seoul 18:00".
  • GitHub Actions cron is locked to UTC (no override). If users expect local time, the wall-clock time will drift through DST.

The 2038 problem

Systems with 32-bit signed Unix time overflow on 2038-01-19 03:14:07 UTC. Even in 2026, some embedded and legacy systems still do. New systems use 64-bit ms (same as JS Date). Watch out for MySQL's TIMESTAMP.

Temporal — the future answer

TC39's Temporal API (Stage 3) fixes every Date trap. Temporal.ZonedDateTime carries an absolute instant and the IANA name. As of 2026 it's behind a flag in some browsers; a polyfill works for new code. Worth learning — it's the successor standard.

Common pitfalls

1. new Date("2026-05-16")

Parsed as UTC midnight. new Date("2026-05-16T00:00") (no Z) is local midnight. The same string can be 9 hours apart (KST). Always include Z or an explicit offset.

2. toISOString() always returns UTC

Convenient but contradictory if you intended local. Use only when sending to a server.

3. Manual offset arithmetic

Adding/subtracting offsets falls apart on DST boundaries. Let Intl.DateTimeFormat or libraries like date-fns-tz, luxon, or Temporal do it.

4. New project on moment.js

moment.js has been legacy since 2020. New projects: dayjs (same API), date-fns, Luxon, or the Temporal polyfill — better bundle size and tree-shaking.

5. Trusting the browser timezone

VPNs and OS misconfiguration produce wrong client timezones. For important decisions (recurring notifications), let the user explicitly pick.

Summary

  • A Date is a UTC instant. Timezone enters at display.
  • Store and transmit UTC (ISO 8601 + Z, or epoch). Never local.
  • IANA names are the right currency. Fixed offsets are for display and logs only.
  • DST boundaries cause times to repeat or vanish. Schedule cron in UTC.
  • Format with Intl.DateTimeFormat. New projects should consider Temporal.
Back to guides