Working with Timezones

May 28, 20267 min read

Timezone Bugs Are Expensive

A timezone bug does not crash your app — it silently corrupts data. Scheduled emails arrive at the wrong hour. Subscription renewals fire a day early. Audit logs show timestamps that do not match reality. By the time you notice, bad data has spread across systems.

The fix starts with principles, not libraries.

Always Store in UTC

UTC never observes Daylight Saving Time. It never shifts. A UTC timestamp means the same thing on every server, in every database, in every country.

# BAD — storing local time
event_time = "2026-05-28 14:00:00"  # What zone?

# GOOD — storing UTC
event_time = "2026-05-28T18:00:00Z"  # 2 PM EDT = 6 PM UTC

Rule: Store every timestamp in UTC. No exceptions. Convert to local time only at the point of display.

Display in Local Time

Users do not think in UTC. When you show 2026-05-28T18:00:00Z to someone in Tokyo, they have to do mental math. Display times in the user's local timezone instead.

// Convert UTC to the user's local timezone
const date = new Date("2026-05-28T18:00:00Z")
console.log(date.toLocaleString())
// In Tokyo:  "2026/5/29 03:00:00" (next day!)
// In New York: "5/28/2026, 2:00:00 PM"

JavaScript's Date object converts to the browser's local timezone automatically when you use toLocaleString() or toLocaleDateString().

ISO 8601 Format

ISO 8601 is the international standard for date and time representation. It eliminates ambiguity.

2026-05-28T18:00:00Z          (UTC, denoted by Z)
2026-05-28T14:00:00-04:00     (with offset)
2026-05-28                     (date only — no ambiguity)

Why ISO 8601 wins:

  • Alphabetical sort equals chronological sort
  • Every component has a fixed position
  • Timezone offset is explicit
  • Supported by every major language and database

Always include the offset (Z or +/-HH:MM). A bare string like 2026-05-28T14:00:00 is ambiguous — different systems interpret it as local or UTC inconsistently.

JavaScript Intl API

Modern browsers provide the Intl API for timezone-aware formatting — no libraries required.

Format in a Specific Timezone

const date = new Date("2026-05-28T18:00:00Z")

const londonTime = new Intl.DateTimeFormat("en-GB", {
  timeZone: "Europe/London",
  dateStyle: "full",
  timeStyle: "short",
}).format(date)
// "Thursday, 28 May 2026 at 19:00" (BST, UTC+1)

List Available Timezones

const zones = Intl.supportedValuesOf("timeZone")
// ["Africa/Abidjan", "Africa/Accra", ..., "America/New_York", ...]

Get the Offset for a Specific Date

const formatter = new Intl.DateTimeFormat("en-US", {
  timeZone: "America/New_York",
  timeZoneName: "shortOffset",
})

formatter.format(new Date("2026-01-15T12:00:00Z"))
// "1/15/2026, GMT-5" (EST)

formatter.format(new Date("2026-07-15T12:00:00Z"))
// "7/15/2026, GMT-4" (EDT)

The Intl API handles DST automatically. Use it for display wherever possible.

Timezone Libraries

The Intl API covers most display needs, but complex operations — arithmetic, parsing, range queries — benefit from dedicated libraries.

LibrarySizeStrengthsWeaknesses
date-fns-tz~3 KBModular, tree-shakable, immutableRequires date-fns base
luxon~18 KBRich API, built on Intl, timezone-firstLarger bundle than alternatives
dayjs + plugin~7 KBMoment-compatible API, lightweightTimezone support via optional plugin

Avoid moment.js. It is deprecated, mutable, and 67 KB uncompressed. Modern alternatives are smaller, immutable, and actively maintained.

date-fns-tz Example

import { formatInTimeZone } from "date-fns-tz"

const date = new Date("2026-05-28T18:00:00Z")
formatInTimeZone(date, "Asia/Tokyo", "yyyy-MM-dd HH:mm zzz")
// "2026-05-29 03:00 JST"

Luxon Example

import { DateTime } from "luxon"

DateTime.fromISO("2026-05-28T18:00:00Z")
  .setZone("America/Los_Angeles")
  .toFormat("yyyy-MM-dd hh:mm a ZZZZ")
// "2026-05-28 11:00 AM PDT"

Common Timezone Bugs and Fixes

Bug: Adding Hours Across DST

// BAD — adds 24 hours in local time, which may be 23 or 25 real hours
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)

Fix: Work in UTC for arithmetic, then convert to local for display.

Bug: Parsing Without an Offset

// BAD — JavaScript interprets this as local time
new Date("2026-05-28T14:00:00")
// Result varies by machine timezone

// GOOD — explicit UTC
new Date("2026-05-28T14:00:00Z")
// Same result everywhere

Bug: Storing Timezone Names Separately

// BAD
{ "time": "2026-05-28T14:00:00", "tz": "America/New_York" }

// GOOD — the offset is part of the timestamp
{ "time": "2026-05-28T14:00:00-04:00" }

Storing the timezone separately forces every consumer to recompute the offset. Embedding the offset in the timestamp makes the value self-describing.

Quick Checklist

  • All timestamps stored in UTC
  • ISO 8601 format with offset (Z or +/-HH:MM) used everywhere
  • Local display uses Intl API or a timezone-aware library
  • No hardcoded UTC offsets in configuration
  • No moment.js in dependencies
  • Date arithmetic performed in UTC, not local time
  • DST transition dates tested explicitly

Try It Yourself

Use our free Timezone Converter to verify timezone conversions — and the Timestamp Converter to switch between Unix timestamps and readable dates.