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.

Four symptoms, one root causeFour symptoms all trace to mixing an absolute instant with a local renderingFour familiar symptoms, one root causeTimestamp 3h off for other regionDate-only drifts to previous daySSR/client hydration mismatchNightly job fires twice or skipsRoot cause:mixing the absolute instantwith one 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.

Two accessor lenses on one instantAbsolute accessors are host-independent data; local accessors apply the host zone for displayOne instant, read through two lensesUTC instantAbsolute lensgetTime() ยท toISOString()getUTCHours() ยท getUTCDate()host-independent โ†’ the dataLocal lensgetHours() ยท getDate()getMonth() ยท toString()applies host zone โ†’ display only

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.

Reading one instant three waysgetTime and getUTCHours are host-independent; getHours varies by host zoneSame object, three readsgetTime()1710504000000same everywheregetUTCHours()12same everywheregetHours()7 in Chicago ยท 21 in Tokyohost zone

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.

Two parallel Date accessor familiesgetUTC and bare accessors read the same instant through different lenses; mixing them driftsTwo parallel families, one stored instantstored UTC instantgetUTC* familyreads the UTC fieldsalways 12:00bare familyapplies host zonevaries by machineMixing them (Date.UTC(getFullYear()โ€ฆ))blends the lenses โ†’ wrong day

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.

Instant plus zone yields ZonedDateTimeAttaching an IANA zone to an Instant resolves offset and DST; Intl renders any zoneAttach a zone at the display edgeTemporal.Instantabsolute, zone-free.toZonedDateTimeISO(zone)ZonedDateTimeoffset + DST resolvedIntl.DateTimeFormat(loc, { timeZone }).format(instant)one instant โ†’ any IANA zone, never mutated

Production Implementation

The production pattern that keeps UTC and local time straight is "store in UTC, reason in UTC, display in the user's zone" โ€” with the crucial addition that when the wall-clock intent matters, you store the zone too, not just the instant. A log timestamp is a pure moment, so a UTC instant is a complete record. A user's appointment is a wall-clock time in a place, so collapsing it to a UTC instant loses the intent needed to show it correctly after a rule change; persist the full zoned value. Deciding, per field, whether you are recording a moment or a wall-clock-in-a-place is the design choice that determines which representation is correct.

For display, always project into an explicit zone rather than relying on the host default, and make that zone a visible parameter so the same instant renders correctly for every user. The single most common production bug in this area is formatting a stored UTC instant with the server's local getters, which shows the server's wall clock instead of the user's โ€” correct on a UTC server, wrong the moment the server moves or a user is elsewhere. Routing every display through Intl.DateTimeFormat with a pinned timeZone, or through a Temporal ZonedDateTime in the chosen zone, makes the presentation zone explicit and the output reproducible.

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.

SSR sends the instant and localizes on the clientServer transmits epoch or ISO; the client localizes with the user zone, avoiding hydration driftSend the instant, localize at the edgeServer / edgehost zone unknown(usually UTC)epoch / ISOClientlocalizes withuser's zoneNo host-zone read on the server โ†’ no hydration mismatch

Edge Cases

The off-by-one-day bug is the signature edge case of confusing UTC and local time, and it clusters around midnight. An instant that is late evening in the Americas is already the next calendar day in UTC, so reading its date in the wrong zone shifts it by one โ€” which surfaces in "today's" filters, date labels, streak counters, and anniversary checks. The fix is always to reduce the instant to a civil date in the zone whose midnight defines the boundary you care about, usually the user's. Testing this means asserting the date near midnight in a zone far from UTC, because a test near UTC will not reveal the bug.

The daylight-saving edges compound the confusion. A wall-clock time can be nonexistent (spring-forward gap) or doubled (fall-back overlap), so "9:30 AM local" is not always a single instant, and "add one day" to a local time is not always "add 24 hours." These are precisely the cases where treating a value as the wrong kind of time โ€” instant versus wall-clock โ€” produces a visible error, and where an explicit zone plus a disambiguation policy is required. A subtler edge case is that the user's zone can differ from both the server's and the browser's reported zone (a traveler, a VPN, a manually-set preference), so where correctness matters, treat the zone as data you obtain deliberately rather than infer from the environment.

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.

Three transition-day edge casesSpring-forward gap, fall-back overlap, and date-only off-by-one each need explicit handlingThree transition-day hazardsSpring-forward gap02:00 โ†’ 03:0002:30 never happenedโ†’ disambiguation neededFall-back overlap02:00 โ†’ 01:0001:30 happens twiceโ†’ store the offsetDate-only off-by-one'2024/01/01' = localmidnight โ†’ prev UTC dayโ†’ use PlainDate

Gotchas & Common Pitfalls

Five UTC/local pitfalls and their fixesFive common mistakes mixing absolute and local views, each with a one-line fixPitfallFixMix getUTC* and bare accessorspick one lens per calculationtoLocaleString() with no timeZonealways pass an explicit timeZoneTreat getTimezoneOffset() as constantuse IANA-aware APIs per regionStore local wall-clock stringsstore UTC + the IANA idRebuild UTC date from local partsuse getUTC* parts or toISOString()

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

Host-zone invariance of absolute assertionsThe UTC-field assertion passes identically under UTC, Chicago, and Tokyo host zonesAbsolute assertions pass in every host zonegetUTCHours() === 12TZ=UTCโœ“ passesTZ=America/Chicagoโœ“ passesTZ=Asia/Tokyoโœ“ passes

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.

When should I store UTC versus storing the time zone too?

Store a UTC instant when the value is a pure moment, like a log timestamp โ€” that is a complete record. Store the full zoned value when the wall-clock intent matters, like a user's appointment, because collapsing it to a UTC instant loses the information needed to display it correctly after a daylight-saving rule change. Decide per field whether you are recording a moment or a wall-clock time in a place, and choose the representation accordingly.

What causes the off-by-one-day bug with UTC and local time?

Reading an instant's calendar date in the wrong zone near midnight. An instant that is late evening in the Americas is already the next day in UTC, so taking its date in UTC (for example via toISOString().slice(0,10)) shifts it by one for that user. Reduce the instant to a civil date in the zone whose midnight defines the boundary you care about โ€” usually the user's โ€” and test the date near midnight in a zone far from UTC, since a UTC-ish test machine hides the bug.