JavaScript
Date pitfalls, the Temporal API, and Intl for formatting.
JavaScript’s Date object represents a single instant stored internally as milliseconds since the Unix epoch (1970-01-01T00:00:00 UTC). It has no concept of a stored time zone — it is always UTC under the hood. Methods fall into two families: getUTC*() reads UTC fields, while get*() (e.g. getHours()) reads fields in the runtime’s local zone. This split is the source of most JS date confusion.
Parsing with new Date(string) is treacherous. A date-only string like new Date("2026-06-05") is parsed as UTC midnight per the ECMAScript spec, so in any timezone west of UTC it will display as June 4th. Date-time strings ("2026-06-05T14:00:00") with no offset are parsed as local time. Non-ISO formats have always been implementation-defined and have diverged between browsers — avoid them entirely.
There is no first-class IANA-zone-aware type in legacy JS. For display and formatting in a given time zone, use Intl.DateTimeFormat with a timeZone option, or the toLocaleString shorthand. For serious time-zone arithmetic the legacy Date gives you nothing, so you need either Temporal (below) or a library such as Luxon or date-fns-tz.
Temporal
Temporal redesigns date/time from the ground up. It reached TC39 Stage 4 and is part of ECMAScript 2026. It introduces distinct types — Temporal.Instant (an absolute point in time), Temporal.ZonedDateTime (instant + IANA zone), Temporal.PlainDate, Temporal.PlainDateTime, etc. — so the naive/aware confusion is eliminated at the type level.
Pitfall: new Date("2026-06-05") (date-only ISO string) is parsed as UTC midnight, not local midnight. Calling .getDate() on it in a UTC−5 timezone returns 4, not 5. Always append a time and explicit offset, or use a Temporal.PlainDate once Temporal is available, to avoid this off-by-one-day error.
Pitfall: Temporal is standardized (ES2026, TC39 Stage 4) and now native in most engines — Firefox 139+, Chrome and Edge 144+, and Node 26+ (no flag) — but not yet in every engine. Safari is the hold-out, shipping it only in Technology Preview, which is why MDN still marks Temporal “Limited availability” rather than Baseline. Check support (or ship a polyfill) if Safari or an older runtime is in your matrix, rather than assuming Temporal is available.
See also: Instant vs civil time and Parsing user input.
Go deeper: Temporal types, Intl, and safe parsing patterns
Temporal types at a glance:
| Type | What it represents |
|---|---|
Temporal.Instant |
An absolute point in time (like a Unix timestamp) |
Temporal.ZonedDateTime |
Instant + IANA zone — knows its civil reading |
Temporal.PlainDate |
Calendar date, no time, no zone |
Temporal.PlainTime |
Time of day, no date, no zone |
Temporal.PlainDateTime |
Date + time, no zone (civil/local) |
Temporal.Duration |
A length of time |
Intl for formatting today:
const formatter = new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Paris",
dateStyle: "long",
timeStyle: "short",
});
formatter.format(new Date()); // e.g. "5 June 2026 at 16:00"
Safe parsing with legacy Date:
Always include an explicit offset to avoid ambiguity:
// Unambiguous: always UTC
new Date("2026-06-05T14:00:00Z");
// Unambiguous: explicit offset
new Date("2026-06-05T14:00:00+02:00");
// Ambiguous — avoid: no offset on a datetime string means local time
new Date("2026-06-05T14:00:00");
Because server-side JavaScript (Node.js) runs in a predictable environment, precomputing formatted times server-side and sending them as strings is often safer than relying on client-side Date parsing, especially for initial page renders.
Canonical values
The same valid ISO 8601 / RFC 3339 values, parsed by the legacy Date and by Temporal. See also parsing user input for the mirror problem — sloppy input across engines.
When Temporal rejects a value below, it is because the value is not a valid RFC 9557 string — that is the grammar all Temporal types parse against: RFC 3339 plus optional bracketed tags like [America/New_York]. The RangeError message is implementation-specific: the @js-temporal polyfill these rows were generated with says invalid RFC 9557 string, while V8 phrases it differently, so match on the exception, never on the text. See RFC 9557 on the RFC 3339 page.
Generated at build time · 22.22.3 · @js-temporal/polyfill 0.5.1 · rendered in America/Los_Angeles
- Handled losslessly — or correctly rejected a value this type should not accept.
- Accepted, but lossy or surprising: precision truncated, offset discarded, or a silent assumption made.
- Wrong or unsafe for the input — silently corrupted or invented information (a guessed or shifted value), or a hard rejection of a value that is valid under a standard the tool claims to support.
Date only
2026-06-12
Calendar date — no time, no zone
ISO 8601 not RFC 3339
-
Date
Jun 11, 2026, 5:00:00 PM PDT -
Temporal
2026-06-12
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T00:00:00.000Z |
|
|---|---|---|
| Coerced to an absolute instant (UTC midnight) the string never specified. | ||
getTime() |
1781222400000 |
|
| An epoch value invented from a zone-less calendar date. | ||
render @ America/Los_Angeles |
Jun 11, 2026, 5:00:00 PM PDT |
|
| Parsed as UTC midnight, so west of UTC it renders as the previous calendar day (Jun 11). | ||
Temporal.Instant.from(…) |
RangeError: Temporal.Instant requires a time zone offset | |
|---|---|---|
| Refuses to invent an instant from a zone-less date — exactly the right call. | ||
Temporal.PlainDateTime.from(…) |
2026-06-12T00:00:00 |
|
| Parses to a zone-less date-time at midnight, no instant assumed. | ||
Temporal.PlainDate.from(…) |
2026-06-12 |
|
| Parses to a plain calendar date — the type that actually matches the input. | ||
Gotcha: per the ECMAScript spec a date-only string is parsed as midnight UTC, not local midnight. In any zone west of UTC, rendering it back as a local date shows the previous day. (Note the asymmetry with local date-time, which is parsed as local time.)
Local date-time
2026-06-12T12:34:56.123456789
Date and time, no zone
ISO 8601 not RFC 3339
-
Date
Jun 12, 2026, 5:34:56 AM PDT -
Temporal
2026-06-12T12:34:56.123456789
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T12:34:56.123Z |
|
|---|---|---|
| Assumes browser-local time and truncates the nanoseconds to .123 ms. | ||
getTime() |
1781267696123 |
|
| Epoch derived from a local-time assumption the string never made. | ||
render @ America/Los_Angeles |
Jun 12, 2026, 5:34:56 AM PDT |
|
| Interprets the zone-less time as browser-local — the rendered instant varies per reader. | ||
Temporal.Instant.from(…) |
RangeError: Temporal.Instant requires a time zone offset | |
|---|---|---|
| Refuses to fabricate an instant from a zone-less date-time. | ||
Temporal.PlainDateTime.from(…) |
2026-06-12T12:34:56.123456789 |
|
| Preserves the full nanosecond-precision wall-clock time with no zone assumption. | ||
Temporal.PlainDate.from(…) |
2026-06-12 |
|
| Extracts just the calendar date, dropping the time component as requested. | ||
Note: with no zone, Date assumes browser-local time, so the absolute instant differs per reader. And Date stores only milliseconds — .123456789 truncates to .123; Temporal keeps nanoseconds.
Excess precision
2026-06-12T12:34:56.12345678987Z
11 fractional digits (beyond nanosecond), UTC
ISO 8601 RFC 3339
-
Date
2026-06-12T12:34:56.123Z -
Temporal
RangeError: invalid RFC 9557 string: 2026-06-12T12:34:56.12345678987Z
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T12:34:56.123Z |
|
|---|---|---|
| Silently truncates the 11-digit fraction to milliseconds (.123). | ||
getTime() |
1781267696123 |
|
| Epoch is correct only to milliseconds; the sub-ms fraction is gone. | ||
render @ America/Los_Angeles |
Jun 12, 2026, 5:34:56 AM PDT |
|
| Renders correctly but only to millisecond precision. | ||
Temporal.Instant.from(…) |
RangeError: invalid RFC 9557 string: 2026-06-12T12:34:56.12345678987Z | |
|---|---|---|
| Rejects the value — 11 fractional digits exceed nanosecond precision rather than being silently dropped. | ||
Temporal.PlainDateTime.from(…) |
RangeError: invalid RFC 9557 string: 2026-06-12T12:34:56.12345678987Z | |
| Rejects the over-precise fraction instead of truncating it. | ||
Temporal.PlainDate.from(…) |
RangeError: invalid RFC 9557 string: 2026-06-12T12:34:56.12345678987Z | |
| Rejects the over-precise fraction instead of truncating it. | ||
Gotcha: 11 fractional digits exceed nanosecond resolution. Date silently truncates to milliseconds and Python to microseconds, so both accept a value they can't faithfully hold; Temporal refuses it outright.
With offset
2026-06-12T12:34:56.123456789+05:30
Date-time with explicit +05:30 offset
ISO 8601 RFC 3339
-
Date
2026-06-12T07:04:56.123Z -
Temporal
2026-06-12T07:04:56.123456789Z
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T07:04:56.123Z |
|
|---|---|---|
| Right instant, but the +05:30 offset is discarded and nanoseconds truncated to ms. | ||
getTime() |
1781247896123 |
|
| Correct epoch, but the original offset and sub-ms fraction are lost. | ||
render @ America/Los_Angeles |
Jun 12, 2026, 12:04:56 AM PDT |
|
| Localizes the instant correctly, but the source +05:30 offset is gone. | ||
Temporal.Instant.from(…) |
2026-06-12T07:04:56.123456789Z |
|
|---|---|---|
| Converts to a UTC instant losslessly, preserving nanosecond precision. | ||
Temporal.PlainDateTime.from(…) |
2026-06-12T12:34:56.123456789 |
|
| Keeps the wall-clock fields but silently drops the +05:30 offset. | ||
Temporal.PlainDate.from(…) |
2026-06-12 |
|
| Extracts the date but discards both the time and the offset. | ||
Note: the +05:30 offset fixes the absolute instant, but it is not a time zone. Date keeps only the instant (offset gone); Temporal's PlainDateTime keeps the wall-clock fields and drops the offset, while Instant keeps the instant — pick the type that matches what you need.
UTC (Z)
2026-06-12T12:34:56.123456789Z
Date-time anchored to UTC via Z
ISO 8601 RFC 3339
-
Date
2026-06-12T12:34:56.123Z -
Temporal
2026-06-12T12:34:56.123456789Z
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T12:34:56.123Z |
|
|---|---|---|
| Correct instant, but the nanosecond fraction is truncated to milliseconds (.123). | ||
getTime() |
1781267696123 |
|
| Epoch is correct but millisecond-only; nanoseconds are lost. | ||
render @ America/Los_Angeles |
Jun 12, 2026, 5:34:56 AM PDT |
|
| Renders correctly but only to millisecond precision. | ||
Temporal.Instant.from(…) |
2026-06-12T12:34:56.123456789Z |
|
|---|---|---|
| Exact instant preserved to nanoseconds. | ||
Temporal.PlainDateTime.from(…) |
RangeError: Z designator not supported for PlainDateTime | |
| Rejects the Z designator — a plain date-time must not carry a UTC anchor. | ||
Temporal.PlainDate.from(…) |
RangeError: Z designator not supported for PlainDate | |
| Rejects the Z designator — a plain date must not carry a UTC anchor. | ||
Note: a clean UTC instant — the easy case. Both Date and Python still truncate the nanosecond fraction (to ms and µs respectively); only Temporal preserves all nine digits.
Space separator
2026-06-12 12:34:56Z
Space instead of T, UTC
not ISO 8601 RFC 3339
-
Date
2026-06-12T12:34:56.000Z -
Temporal
2026-06-12T12:34:56Z
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T12:34:56.000Z |
|
|---|---|---|
| Accepts the RFC 3339 space-separator variant and parses the instant correctly. | ||
getTime() |
1781267696000 |
|
| Correct epoch for the UTC instant. | ||
render @ America/Los_Angeles |
Jun 12, 2026, 5:34:56 AM PDT |
|
| Localizes the parsed UTC instant correctly. | ||
Temporal.Instant.from(…) |
2026-06-12T12:34:56Z |
|
|---|---|---|
| Parses the space-separator RFC 3339 string to the correct instant. | ||
Temporal.PlainDateTime.from(…) |
RangeError: Z designator not supported for PlainDateTime | |
| Rejects the Z designator — appropriate for a zone-less type. | ||
Temporal.PlainDate.from(…) |
RangeError: Z designator not supported for PlainDate | |
| Rejects the Z designator — appropriate for a zone-less type. | ||
Note: the space separator is legal in RFC 3339 but not in ISO 8601, which requires a T. Every engine here accepts it anyway — a reminder that the two standards disagree at the edges.
Comma fraction
2026-06-12T12:34:56,123Z
Comma decimal sign, UTC
ISO 8601 not RFC 3339
-
Date
Invalid Date -
Temporal
2026-06-12T12:34:56.123Z
Go deeper: every method
new Date(…).toISOString() |
Invalid Date | |
|---|---|---|
| Rejects the ISO 8601 comma decimal sign — a valid form Date cannot parse. | ||
getTime() |
NaN | |
| Returns NaN on the comma-fraction form. | ||
render @ America/Los_Angeles |
Invalid Date | |
| Invalid Date — cannot render the comma-fraction form. | ||
Temporal.Instant.from(…) |
2026-06-12T12:34:56.123Z |
|
|---|---|---|
| Accepts the ISO 8601 comma fraction and parses the instant correctly. | ||
Temporal.PlainDateTime.from(…) |
RangeError: Z designator not supported for PlainDateTime | |
| Rejects the Z designator — appropriate for a zone-less type. | ||
Temporal.PlainDate.from(…) |
RangeError: Z designator not supported for PlainDate | |
| Rejects the Z designator — appropriate for a zone-less type. | ||
Gotcha: ISO 8601 permits a comma as the decimal sign (56,123); RFC 3339 requires a dot. Date rejects the comma form outright, while Temporal and Python stdlib accept it — the boundary between the two standards in one character.
Unknown offset
2026-06-12T12:34:56-00:00
Negative-zero offset
not ISO 8601 RFC 3339
-
Date
2026-06-12T12:34:56.000Z -
Temporal
2026-06-12T12:34:56Z
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T12:34:56.000Z |
|
|---|---|---|
| -00:00 (unknown offset per RFC 3339) is silently flattened to UTC +00:00. | ||
getTime() |
1781267696000 |
|
| Epoch treats the unknown offset as plain UTC. | ||
render @ America/Los_Angeles |
Jun 12, 2026, 5:34:56 AM PDT |
|
| Renders as UTC; the 'offset unknown' signal of -00:00 is lost. | ||
Temporal.Instant.from(…) |
2026-06-12T12:34:56Z |
|
|---|---|---|
| Accepts -00:00 and normalizes to UTC, dropping the RFC 3339 'unknown offset' nuance. | ||
Temporal.PlainDateTime.from(…) |
2026-06-12T12:34:56 |
|
| Keeps the wall-clock fields but discards the -00:00 offset entirely. | ||
Temporal.PlainDate.from(…) |
2026-06-12 |
|
| Extracts the date and discards the offset. | ||
Gotcha: RFC 3339 uses -00:00 to mean “UTC, but the real local offset is unknown.” Every engine here collapses it to plain UTC +00:00, silently discarding that “unknown” nuance.
Lowercase t/z
2026-06-12t12:34:56z
Lowercase date-time designators
not ISO 8601 RFC 3339
-
Date
2026-06-12T12:34:56.000Z -
Temporal
2026-06-12T12:34:56Z
Go deeper: every method
new Date(…).toISOString() |
2026-06-12T12:34:56.000Z |
|
|---|---|---|
| Accepts the lowercase t/z designators, which RFC 3339 declares case-insensitive. | ||
getTime() |
1781267696000 |
|
| Correct epoch for the lowercase-designator instant. | ||
render @ America/Los_Angeles |
Jun 12, 2026, 5:34:56 AM PDT |
|
| Localizes the parsed instant correctly. | ||
Temporal.Instant.from(…) |
2026-06-12T12:34:56Z |
|
|---|---|---|
| Accepts the case-insensitive lowercase z and parses the instant correctly. | ||
Temporal.PlainDateTime.from(…) |
RangeError: Z designator not supported for PlainDateTime | |
| Rejects the (lowercase) Z designator — appropriate for a zone-less type. | ||
Temporal.PlainDate.from(…) |
RangeError: Z designator not supported for PlainDate | |
| Rejects the (lowercase) Z designator — appropriate for a zone-less type. | ||
Gotcha: RFC 3339 says the t and z designators are case-insensitive. Date and Temporal accept the lowercase form; Python's fromisoformat rejects it — a real interop hazard for case-careless producers.
Basic format
20260612T123456Z
No separators (ISO basic), UTC
ISO 8601 not RFC 3339
-
Date
Invalid Date -
Temporal
2026-06-12T12:34:56Z
Go deeper: every method
new Date(…).toISOString() |
Invalid Date | |
|---|---|---|
| Rejects ISO 8601 basic (no-separator) format — returns Invalid Date. | ||
getTime() |
NaN | |
| Returns NaN on the basic-format string. | ||
render @ America/Los_Angeles |
Invalid Date | |
| Invalid Date — cannot render the basic-format string. | ||
Temporal.Instant.from(…) |
2026-06-12T12:34:56Z |
|
|---|---|---|
| Accepts ISO basic format and parses the instant correctly. | ||
Temporal.PlainDateTime.from(…) |
RangeError: Z designator not supported for PlainDateTime | |
| Rejects the Z designator — appropriate for a zone-less type. | ||
Temporal.PlainDate.from(…) |
RangeError: Z designator not supported for PlainDate | |
| Rejects the Z designator — appropriate for a zone-less type. | ||
Gotcha: ISO 8601 “basic” format drops the separators (20260612T123456Z). Date returns Invalid Date and Python's date.fromisoformat rejects it, while datetime.fromisoformat and Temporal's Instant accept it — support is patchy.
Week date
2026-W24-3
ISO week-numbering date
ISO 8601 not RFC 3339
-
Date
Invalid Date -
Temporal
RangeError: invalid RFC 9557 string: 2026-W24-3
Go deeper: every method
new Date(…).toISOString() |
Invalid Date | |
|---|---|---|
| Cannot parse ISO week-date notation — returns Invalid Date. | ||
getTime() |
NaN | |
| Returns NaN on the week-date string. | ||
render @ America/Los_Angeles |
Invalid Date | |
| Invalid Date — cannot render the week-date string. | ||
Temporal.Instant.from(…) |
RangeError: invalid RFC 9557 string: 2026-W24-3 | |
|---|---|---|
| Rejects week-date notation, which RFC 9557 does not include. | ||
Temporal.PlainDateTime.from(…) |
RangeError: invalid RFC 9557 string: 2026-W24-3 | |
| Rejects week-date notation, unsupported by RFC 9557. | ||
Temporal.PlainDate.from(…) |
RangeError: invalid RFC 9557 string: 2026-W24-3 | |
| Rejects week-date notation, unsupported by RFC 9557. | ||
Gotcha: the ISO week-numbering date 2026-W24-3 is valid ISO 8601 but unsupported by Date and Temporal (it isn't in RFC 9557). Python does parse it, to the calendar date 2026-06-10 — correct, but easy to overlook.
US slash + 12h
5/12/23 5pm
Human-typed US-style date and time
not ISO 8601 not RFC 3339 human-friendly
-
Date
Invalid Date -
Temporal
RangeError: invalid RFC 9557 string: 5/12/23 5pm
Go deeper: every method
new Date(…).toISOString() |
Invalid Date | |
|---|---|---|
| Refuses the ambiguous human-typed input — returns Invalid Date. | ||
getTime() |
NaN | |
| Returns NaN rather than guessing at the ambiguous input. | ||
render @ America/Los_Angeles |
Invalid Date | |
| Invalid Date — refuses to render the ambiguous input. | ||
Temporal.Instant.from(…) |
RangeError: invalid RFC 9557 string: 5/12/23 5pm | |
|---|---|---|
| Rejects the non-ISO human-typed input. | ||
Temporal.PlainDateTime.from(…) |
RangeError: invalid RFC 9557 string: 5/12/23 5pm | |
| Rejects the non-ISO human-typed input. | ||
Temporal.PlainDate.from(…) |
RangeError: invalid RFC 9557 string: 5/12/23 5pm | |
| Rejects the non-ISO human-typed input. | ||
Gotcha: 5/12/23 5pm is human input, not a machine format — is that May 12 or December 5? Every engine here refuses it. Don't feed free-form text to a date constructor; parse it explicitly with a locale-aware library.
Spelled-out
June 5, 2026 5:00 PM
Human-typed, spelled-out month
not ISO 8601 not RFC 3339 human-friendly
-
Date
2026-06-05T17:00:00.000Z -
Temporal
RangeError: invalid RFC 9557 string: June 5, 2026 5:00 PM
Go deeper: every method
new Date(…).toISOString() |
2026-06-05T17:00:00.000Z |
|
|---|---|---|
| Silently guesses at the spelled-out date via implementation-defined heuristics — returns a value where an error would be safer. | ||
getTime() |
1780678800000 |
|
| Returns an epoch from a heuristic guess at free-form text. | ||
render @ America/Los_Angeles |
Jun 5, 2026, 10:00:00 AM PDT |
|
| Renders a value the engine guessed at — behaviour varies across runtimes. | ||
Temporal.Instant.from(…) |
RangeError: invalid RFC 9557 string: June 5, 2026 5:00 PM | |
|---|---|---|
| Rejects the non-ISO spelled-out input rather than guessing. | ||
Temporal.PlainDateTime.from(…) |
RangeError: invalid RFC 9557 string: June 5, 2026 5:00 PM | |
| Rejects the non-ISO spelled-out input rather than guessing. | ||
Temporal.PlainDate.from(…) |
RangeError: invalid RFC 9557 string: June 5, 2026 5:00 PM | |
| Rejects the non-ISO spelled-out input rather than guessing. | ||
Gotcha: JavaScript's Date happily guesses at June 5, 2026 5:00 PM — interpreting it in the runtime's local zone via implementation-defined heuristics. Temporal and Python reject it. A returned value here is more dangerous than an error.