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

← All articles