Timezones and UTC: the developer's survival guide

8 min read
datetime
guide

Almost every timezone bug in production traces back to one of five mistakes, and every one of them is avoidable with a small set of rules applied consistently.

Rule 1: store in UTC, always

A timestamp stored as 2026-08-09 14:00:00 with no offset is ambiguous — 2pm where? Store either an ISO 8601 string with an explicit offset (2026-08-09T14:00:00Z) or, better, a Unix timestamp (seconds or milliseconds since epoch), which has no timezone concept at all because it's just a count of elapsed seconds.

// Good: unambiguous, timezone-independent
const createdAt = Date.now(); // 1754748000000
const isoUtc = new Date().toISOString(); // "2026-08-09T14:00:00.000Z"

// Bad: which timezone is this in?
const createdAt = "2026-08-09 14:00:00";

Database columns should be TIMESTAMP WITH TIME ZONE in Postgres (which actually stores UTC internally and converts on display) rather than TIMESTAMP WITHOUT TIME ZONE, which silently drops offset information and inherits whatever the connection's session timezone happens to be.

Rule 2: convert to local time only at the last moment — for display

Do date math, comparisons, and storage in UTC. Convert to the user's local timezone only when rendering to a screen:

new Date(isoUtc).toLocaleString("en-US", { timeZone: userTimezone });

The moment you convert early and then do arithmetic on the local time, you inherit every DST edge case that arithmetic on UTC avoids for free.

Rule 3: UTC offset and IANA timezone are not the same thing

+05:30 is an offset. Asia/Kolkata is a timezone name (an IANA tz database identifier). The critical difference: an offset is a fixed number, but a timezone's offset can change over the year due to daylight saving. Storing "UTC+2" for a European user's preference is a bug waiting for the next DST transition — six months later that offset is wrong. Store "Europe/Berlin" and let a timezone-aware library resolve the correct offset for any given date.

// Bug: fixed offset, breaks across DST
const offset = "+02:00";

// Correct: named zone, offset resolved per-date
const zone = "Europe/Berlin";

Rule 4: date-only values need special handling

A birthday, a deadline, or a "delivery date" is often meant to be timezone-independent — August 9th should mean August 9th no matter where the user is. The classic bug is storing it as a full Date object at midnight UTC and then displaying it in a timezone behind UTC, where midnight UTC on the 9th becomes 7pm on the 8th locally — the date silently shifts backward by a day.

The fix: for pure dates with no time component, store and compare them as plain YYYY-MM-DD strings, or use a date-only type if your database has one (Postgres has DATE), and never round-trip them through a timezone-aware Date object unless you're deliberately attaching a time.

Rule 5: DST arithmetic is not "add 24 hours"

"Tomorrow at the same time" and "add 24 hours" disagree twice a year in any timezone that observes daylight saving. Adding 24 hours to 1:30am on the day before a spring-forward transition can land you on 2:30am or 3:30am depending on the library, while "same time tomorrow" should land on 1:30am the next calendar day. Use a library's calendar-aware "add one day" function, not raw millisecond arithmetic, whenever the operation is meant to be calendar-based rather than duration-based:

import { addDays } from "date-fns";
addDays(new Date("2026-03-08T01:30:00-05:00"), 1); // handles the DST jump correctly

A quick reference for common conversions

  • Unix timestamp → human date: multiply by 1000 if it's in seconds (a 10-digit number) before passing to new Date(), which expects milliseconds; a 13-digit number is already milliseconds. Mixing these up is the single most common timestamp bug and produces a date around 1970 or a date far in the future.
  • Cross-timezone meeting time: never eyeball offset arithmetic by hand across a DST boundary; convert through UTC and let a library resolve each participant's local time, since two cities' offsets relative to UTC can each shift independently.
  • "Today" for a user: compute using the user's timezone, not the server's — a server running in UTC will think it's already tomorrow for a user in California for several hours every day.

The one habit that prevents most of this

Whenever a timestamp crosses a serialization boundary — API response, database write, log line, URL parameter — write down explicitly which format and zone it's in, in the code or the schema comment. Ambiguity is what causes these bugs; an explicit "created_at is Unix ms UTC" comment costs one line and saves the multi-hour debugging session six months later when someone assumes seconds.

Tools from this article

Comments

No login needed. Comments appear after a quick review.

Protected by an on-site captcha — no third-party trackers.

Optional: get an alert when your comment is published or replied to, plus new tool announcements. No sign-up.

Loading comments…

← All articles