Understanding UTC vs Local Time in JS

How JavaScript stores one absolute instant in UTC yet renders it as many different wall-clock times, and how to keep the two apart in production code. Part of JavaScript Date Fundamentals & Core Concepts.

What Breaks When You Confuse The Two

A Date holds a single number — milliseconds since the Unix epoch — that is identical on every machine on Earth. The moment you read it with getHours() or print it with toString(), the runtime applies the host operating system's timezone and produces a local view. That view is a presentation, not the data, and treating it as data is the single largest source of date bugs.

The symptoms are familiar. A timestamp displays three hours off for users in another region. A date-only value drifts to the previous day for anyone west of UTC. A server-rendered page shows one time, the client hydrates with another, and React throws a hydration mismatch. A nightly job scheduled for "02:30 local" either fires twice or not at all on a daylight-saving boundary. Every one of these traces back to mixing the absolute instant with a particular local rendering of it.

One Instant, Many Wall Clocks

The core mental model: there is exactly one instant, and any number of wall-clock readings of it. The diagram below shows a single UTC instant rendered simultaneously in four IANA zones — the number on the wire never changes, only the clock face does.

One UTC instant rendered as several local timesA central UTC instant of 2024-03-15T12:00:00Z fans out to four IANA timezones — Los Angeles, Chicago, London, and Tokyo — each showing a different wall-clock time and offset for the very same moment.One instant on the wire, four clock facesUTC instant2024-03-15T12:00:00ZAmerica/Los_Angeles05:00UTC-07:00 (PDT)America/Chicago07:00UTC-05:00 (CDT)Europe/London12:00UTC+00:00 (GMT)Asia/Tokyo21:00UTC+09:00 (JST)getTime() is the same everywhere; getHours() differs by host zone.

The number 1710504000000 is the data. 05:00, 07:00, 12:00, and 21:00 are four equally valid renderings of it. Store and transmit the number; compute the clock face only at the display edge. The same absolute-versus-wall-clock split underpins timezone offset arithmetic and every epoch conversion in Unix timestamps and epoch conversion.

API Reference

API View Returns Timezone caveat
Date.now() / getTime() absolute number (ms) Host-independent; the true data.
date.toISOString() absolute string UTC with Z; no offset math needed.
getFullYear() / getHours() local number Applies the host OS zone silently.
getUTCFullYear() / getUTCHours() UTC number Reads the stored UTC fields.
date.toLocaleString(loc, opts) local string Host zone unless you pass timeZone.
Intl.DateTimeFormat(loc, { timeZone }) chosen string The correct display tool; cache it.
Intl.DateTimeFormat().resolvedOptions().timeZone IANA string The host's IANA id.
Temporal.Instant absolute Instant Zone-free point in time.
Temporal.ZonedDateTime absolute + zone ZonedDateTime Carries an explicit IANA zone + calendar.

The Internal Clock Versus The User View

getTime() and Date.now() return epoch milliseconds, completely independent of the host timezone. Every accessor that lacks the UTC infix — getHours(), getDate(), getMonth(), getDay() — applies the operating system zone to produce a local view. So getTimezoneOffset() is the only built-in window into what zone you are actually running in, and it reports the host's current offset, nothing about a specific region.

// The trailing 'Z' forces UTC interpretation across all compliant engines.
const instant = new Date('2024-03-15T12:00:00Z');

console.log(instant.getTime());      // 1710504000000 — identical on every machine
console.log(instant.toISOString());  // '2024-03-15T12:00:00.000Z' — the stored UTC value
console.log(instant.getUTCHours());  // 12 — reads the UTC field directly
console.log(instant.getHours());     // varies: 7 in Chicago, 21 in Tokyo, 12 in London

The rule that prevents most bugs:

When ingesting external timestamps, never rely on implicit local parsing; the rules differ between date-only and datetime strings. See Parsing ISO 8601 strings safely for patterns that prevent silent conversion.

Approach A: The Legacy Date Method Families

The Date API exposes two parallel accessor families. Reading the UTC fields and the local fields of the same object gives different answers, and mixing them in one calculation produces silent drift.

const d = new Date('2024-03-15T12:00:00Z');

// Parallel families read the SAME stored instant through two different lenses.
const utcView   = { y: d.getUTCFullYear(), mo: d.getUTCMonth(), h: d.getUTCHours() };
const localView = { y: d.getFullYear(),    mo: d.getMonth(),    h: d.getHours()    };
// utcView.h is always 12; localView.h depends entirely on the host OS zone.

// Anti-pattern: building a "UTC" date out of LOCAL parts. The day can be wrong.
const broken = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()); // mixes the families

The legacy limitation is that Date can only render in two zones: UTC (via the getUTC* family) and whatever the host happens to be (via the bare accessors). It cannot show an arbitrary zone such as Asia/Kolkata without Intl. For correct UTC output, skip the accessors entirely — toISOString() serialises the stored value directly and is always right.

Approach B: Explicit Zones With Intl & Temporal

Intl.DateTimeFormat renders one immutable UTC instant into any IANA zone without ever mutating it. This is the correct display tool; pass an explicit timeZone so output never depends on the host.

const eventInstant = new Date('2024-03-15T12:00:00Z'); // immutable UTC source

// Cache the formatter — construction is the expensive part, not format().
const chicago = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/Chicago', // explicit zone: identical on server and client
  dateStyle: 'medium',
  timeStyle: 'short',
});

console.log(chicago.format(eventInstant)); // 'Mar 15, 2024, 7:00 AM'

Temporal makes the absolute/wall-clock split a type-level distinction. Temporal.Instant is a zone-free absolute point; attaching an IANA zone yields a Temporal.ZonedDateTime that resolves offsets and DST from the timezone database, never from raw arithmetic.

import { Temporal } from '@js-temporal/polyfill';

// Instant is the absolute point — the equivalent of epoch milliseconds, zone-free.
const instant = Temporal.Instant.from('2024-03-10T06:30:00Z');

// Attaching a zone produces the local wall-clock view; DST is resolved automatically.
const zoned = instant.toZonedDateTimeISO('America/New_York');
console.log(zoned.toString()); // '2024-03-10T02:30:00-04:00[America/New_York]'
console.log(zoned.hour);       // 2 — local wall hour, with the offset already applied

Going the other direction — taking a wall-clock value the user typed and recovering the UTC instant — is the precise focus of How to convert local time to UTC in JavaScript.

Production Implementation

A reliable rendering helper takes an absolute instant plus an explicit target zone, validates both, and caches formatters by locale|zone|style so a hot render path never reconstructs them. It must not read the host zone implicitly.

const formatterCache = new Map<string, Intl.DateTimeFormat>();

interface RenderOpts {
  locale?: string;
  timeZone: string; // required: never fall back to the host zone for display
  dateStyle?: 'full' | 'long' | 'medium' | 'short';
  timeStyle?: 'full' | 'long' | 'medium' | 'short';
}

/** Render an absolute instant (Date or epoch ms) in an explicit IANA zone. */
function renderInstant(instant: Date | number, opts: RenderOpts): string {
  const date = typeof instant === 'number' ? new Date(instant) : instant;
  if (Number.isNaN(date.getTime())) {
    throw new TypeError('renderInstant received an invalid instant');
  }
  const { locale = 'en-US', timeZone, dateStyle = 'medium', timeStyle = 'short' } = opts;
  const key = `${locale}|${timeZone}|${dateStyle}|${timeStyle}`;
  let fmt = formatterCache.get(key);
  if (!fmt) {
    // Constructing the formatter validates the IANA zone: a bad id throws RangeError here.
    fmt = new Intl.DateTimeFormat(locale, { timeZone, dateStyle, timeStyle });
    formatterCache.set(key, fmt);
  }
  return fmt.format(date);
}

For SSR and serverless this discipline is non-negotiable. A Lambda or edge worker has an unknowable host zone — almost always UTC — so any display that reads the host clock will mismatch the browser and trigger a hydration error. Send the epoch integer or a UTC ISO string to the client, and localise there with the user's resolved zone, or render server-side with a zone you stored explicitly per user.

Edge Cases

Spring-forward gap (a wall-clock time that never happened)

When clocks jump from 02:00 to 03:00, every local time in that hour is non-existent. Asking Intl to display a UTC instant is always safe — the instant exists regardless — but constructing an instant from a gap-local wall time forces a disambiguation decision. Temporal exposes that decision via the disambiguation option ('earlier', 'later', 'compatible', 'reject'); legacy Date silently shifts.

Fall-back overlap (a wall-clock time that happened twice)

When clocks fall from 02:00 back to 01:00, the hour 01:00–01:59 occurs twice — once at the pre-transition offset, once after. The displayed local time is unambiguous, but a single local string maps to two distinct instants. Persisting the offset (or a full ZonedDateTime string like ...-05:00[America/New_York]) is what disambiguates the two on read-back.

Date-only strings and the off-by-one day

new Date('2024-01-01') parses as UTC midnight per the spec, but new Date('2024/01/01') and most non-ISO forms parse as local midnight. For a user behind UTC, local midnight is the previous day in UTC, so the value formats one day early. Treat date-only input as a calendar date (a Temporal.PlainDate), never as an instant.

Gotchas & Common Pitfalls

Testing Checklist

Scenario Input Expected
UTC field is host-independent new Date('2024-03-15T12:00:00Z').getUTCHours() 12 in every zone
Epoch is host-independent new Date('2024-03-15T12:00:00Z').getTime() 1710504000000
Explicit-zone display render instant in America/Chicago Mar 15, 2024, 7:00 AM
Date-only ISO parse new Date('2024-01-01').toISOString() 2024-01-01T00:00:00.000Z
Spring-forward render instant 2024-03-10T06:30:00Z in America/New_York local 02:30, offset -04:00

Run the suite under several host zones to prove that absolute logic never leaks the machine clock:

# UTC-field and epoch assertions must pass identically in every zone.
for TZ in UTC America/Chicago Asia/Tokyo Pacific/Kiritimati; do TZ=$TZ npx jest utc-local; done

Frequently Asked Questions

Does JavaScript store dates in UTC or local time internally?

In UTC. A Date is a single epoch-millisecond count that is identical on every machine. Local time is computed on demand from that value using the host's timezone rules, so getHours() varies by machine while getTime() and getUTCHours() do not.

Why does new Date('2024-01-01') sometimes show the wrong day?

Date-only ISO strings parse as UTC midnight, but non-ISO forms like '2024/01/01' parse as local midnight. For a host behind UTC, local midnight is the previous calendar day in UTC, so formatting the value as UTC shows it one day early. Handle calendar dates as Temporal.PlainDate, not as instants.

How do I display one timestamp in several timezones?

Keep the single UTC instant and build one cached Intl.DateTimeFormat per target zone, each with an explicit timeZone. Calling format() on the same instant with different formatters yields the correct local wall time for each zone without mutating the source.

Should I use the legacy Date object or Temporal for new code?

Prefer Temporal. It encodes the absolute/wall-clock split in the type system — Instant for absolute points, ZonedDateTime for zone-aware ones, PlainDate/PlainDateTime for calendar values — and resolves DST from the timezone database instead of silent arithmetic. Use Date only where a dependency requires it.