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.
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:
- Business logic, comparison, persistence, transport โ use the absolute value: epoch milliseconds,
getUTC*/setUTC*, ortoISOString(). - Display only โ apply a zone, ideally via
Intl.DateTimeFormatwith an explicittimeZone.
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
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.
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.
Gotchas & Common Pitfalls
- Mixing
getUTC*and bare accessors in one calculation. Fix: pick one lens โ UTC for logic, local only for display โ and never combine them. - Calling
toLocaleString()with notimeZone. It silently uses the host zone, so server output differs from the browser. Fix: always pass an explicittimeZone. - Treating
getTimezoneOffset()as a constant. It changes across DST and reflects only the host zone. Fix: use IANA-aware APIs for any specific region. - Storing local wall-clock strings. They become unrecoverable when DST rules or user regions change. Fix: store UTC plus the IANA id.
- Reconstructing a "UTC" date from local accessor parts.
Date.UTC(d.getFullYear(), โฆ)blends the families and can land on the wrong day. Fix: usegetUTC*parts ortoISOString().
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.
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.