Guide

Date, time and timezones explained

A user in Tokyo schedules a video call for "9:00 AM tomorrow." Your server in Virginia stores it as a string with no timezone. When daylight saving time ends in New York, a cron job fires twice and sends duplicate invoices. These are not edge cases — they are the default failure mode when applications treat datetime as simple numbers. This guide explains how civil time actually works, why UTC is the storage layer, how ISO 8601 and Unix timestamps differ, the traps in JavaScript's built-in Date, and a checklist for calendars, billing, and APIs — connecting to JavaScript scheduling and the clock-skew concerns that appear in distributed systems.

The three layers of time

Developers often conflate three distinct concepts:

  • Instant — a single point on the universal timeline (e.g. the moment a block was mined). Represent as UTC or as milliseconds since the Unix epoch (1970-01-01T00:00:00Z). Instants have no timezone; they are absolute.
  • Civil datetime — a wall-clock reading in a specific region: "2026-06-07 14:30 in America/New_York." Same instant, different strings in Tokyo or London.
  • Duration — elapsed time between two instants (90 minutes). Durations are timezone-agnostic but are not the same as calendar arithmetic (adding one "day" across a DST boundary).

UTC (Coordinated Universal Time) is the global reference for instants. It replaced GMT for most technical use and does not observe daylight saving. Local zones are offsets from UTC — America/Los_Angeles is UTC−8 in winter and UTC−7 in summer because of DST rules that change by country, state, and sometimes year (political decisions, not physics).

Never store "local time without a zone" in a database. Always persist either an instant (UTC timestamp) or a civil datetime plus an IANA timezone identifier like Europe/Berlin. Offsets alone (UTC+2) are insufficient because they do not encode DST transitions.

ISO 8601 and Unix timestamps

ISO 8601 is the interchange format APIs and logs should use:

2026-06-07T16:30:00Z          // instant in UTC (Z = Zulu)
2026-06-07T09:30:00-07:00     // same instant, Pacific offset
2026-06-07                      // date only — ambiguous for global apps
P1DT2H30M                     // duration: 1 day, 2 hours, 30 minutes

The T separator and explicit offset (or trailing Z) remove ambiguity. Sort lexicographically when zero-padded — a useful property for log files and JSON arrays.

A Unix timestamp stores seconds (or milliseconds) since the epoch. It is compact and timezone-free — ideal for internal storage, cache keys, and blockchain slot times. Downsides: not human-readable, Y2038 limit for 32-bit seconds, and easy to confuse seconds vs milliseconds (off-by-1000 bugs are legendary). JSON APIs often expose ISO strings to clients and integers only where bandwidth matters.

For recurring events ("every Tuesday at 3 PM local"), neither a single instant nor a naive timestamp works. You need a recurrence rule tied to a timezone — RFC 5545 iCalendar RRULE with VTIMEZONE, or libraries like rrule.js that expand occurrences correctly across DST.

Daylight saving time traps

DST creates two classic bugs:

  • Spring forward (gap) — clocks jump from 2:00 AM to 3:00 AM. Local times between 2:00 and 2:59 "do not exist." Scheduling "2:30 AM" on that date is invalid; libraries should shift forward or reject the input.
  • Fall back (overlap) — clocks repeat the 1:00–1:59 hour. Two different instants share the same wall time. Without timezone context, you cannot tell which occurrence a user meant.

Cron expressions like 0 2 * * * in local time may run zero, one, or two times on transition days. Production schedulers (Quartz, systemd timers, cloud cron) document their DST behavior — read it before billing runs. Prefer running critical jobs in UTC and converting display only at the UI layer.

"All-day" calendar events are another trap. An all-day meeting on June 7 is midnight-to-midnight in the organizer's zone, not UTC midnight. Export to Google Calendar or Outlook with the wrong assumption and the event shifts for attendees in other regions.

JavaScript: Date, Intl, and Temporal

JavaScript's legacy Date object wraps milliseconds since epoch but exposes most methods in the runtime's local timezone (getHours()) or UTC (getUTCHours()). Parsing is historically inconsistent:

new Date('2026-06-07')           // UTC midnight in modern engines
new Date('2026-06-07T09:00:00')    // LOCAL time — not UTC!
new Date('2026-06-07T09:00:00Z')   // UTC — safe for APIs

Never parse date-only strings for cross-timezone logic without an explicit zone. For display, use Intl.DateTimeFormat with a locale and timeZone option rather than manual offset math:

new Intl.DateTimeFormat('en-US', {
  dateStyle: 'medium',
  timeStyle: 'short',
  timeZone: 'America/Chicago'
}).format(new Date('2026-06-07T16:30:00Z'));

The staged Temporal API (Temporal.Instant, Temporal.ZonedDateTime, Temporal.PlainDate) separates instants, zoned datetimes, and plain dates explicitly — eliminating many Date foot-guns. Polyfill with @js-temporal/polyfill until browser support is universal. On the server, Python's zoneinfo, Java's ZonedDateTime, and Go's time.LoadLocation embed the IANA database — ship updated tzdata with OS patches.

Libraries like date-fns-tz, Luxon, and Day.js with timezone plugins wrap the same rules with clearer APIs than raw Date. Pick one per codebase; mixing three datetime libraries guarantees inconsistent DST handling.

Database and API patterns

PostgreSQL offers timestamptz (stores UTC, displays in session zone), timestamp (no zone — avoid for instants), and date for calendar dates. MySQL's DATETIME has no zone; prefer TIMESTAMP (UTC storage) or store UTC explicitly. Always set session timezone to UTC in connection pools — see connection pooling for why long-lived sessions inherit server defaults.

REST APIs should document one format in OpenAPI specs — ISO 8601 UTC strings are the de facto standard. Accept client offsets but normalize to UTC before persistence. For queries spanning "a calendar day in Tokyo," convert the day's start/end in Asia/Tokyo to UTC instants rather than assuming 00:00–23:59 UTC.

User profile timezone should come from explicit settings, not IP geolocation alone — travelers and VPN users break naive geo inference. Default display to the browser's Intl.DateTimeFormat().resolvedOptions().timeZone and let users override.

Testing and monitoring

Unit tests that only use UTC miss DST bugs. Add fixtures for:

  • US spring forward (second Sunday in March) and fall back (first Sunday in November)
  • Southern hemisphere zones where DST is inverted relative to the US
  • Zones that abolished DST recently (e.g. permanent standard time debates)
  • Leap day (Feb 29) and leap second edge cases in low-level systems

Freeze time in tests with libraries like sinon fake timers or Jest's setSystemTime, but remember that timezone rules still come from the IANA database on the host — pin tzdata version in CI Docker images. Monitor cron job execution counts on DST weekends; duplicate or missing runs show up immediately in metrics.

Common mistakes

  • Storing local time as a bare string — impossible to compare or sort across regions.
  • Using new Date() string parsing for user input — locale-dependent and silently shifted.
  • Adding 86400000 ms for "one day" — DST days are 23 or 25 hours long in civil time.
  • Hardcoding offset -5 for Eastern Time — EST is UTC−5, EDT is UTC−4; only IANA zones track the switch.
  • Formatting with server local time — logs and emails show wrong times for global users.
  • Ignoring timezone in SQL BETWEEN filters — off-by-one-day report bugs at month boundaries.

Decision checklist

  1. Store instants as UTC (timestamptz, Unix ms, or ISO with Z).
  2. Carry IANA timezone IDs (Europe/Paris) for recurring and display logic — never bare offsets alone.
  3. Serialize API payloads as ISO 8601; document the format in your REST API design spec.
  4. Format for users with Intl or Temporal — convert at the edge, not in SQL string concatenation.
  5. Run schedulers and billing crons in UTC; test DST transition weekends explicitly.
  6. Pin tzdata in CI; add regression tests for spring/fall and leap day.
  7. For multi-region deployments, treat clock skew separately from timezone display — NTP drift is an infrastructure concern, not a formatting fix.

Key takeaways

  • Instants are universal; civil datetimes are local — never mix them without an explicit zone.
  • UTC plus IANA timezone IDs is the production storage model; ISO 8601 is the wire format.
  • DST creates invalid and duplicated local times — calendar and cron code must handle both.
  • JavaScript Date is error-prone; prefer Temporal, Luxon, or explicit Intl formatters.
  • Test with real DST fixtures and pinned tzdata — UTC-only CI hides the bugs users hit twice a year.

Related reading