~/blog

new Date('2026-08-12') is UTC midnight, not yours

published

#javascript#dates#debugging

A grey horizontal line across a black background, its left half glowing neon green, with a single hot pink calendar icon sitting where the green ends
Generated illustration

TL;DR

new Date('2026-08-12') is parsed as UTC midnight. new Date('2026-08-12T00:00:00') — same day, explicit time, no offset — is parsed as local midnight. On a UTC−6 machine the first one prints as August 11:

$ TZ=America/Mexico_City node -e "console.log(new Date('2026-08-12').toLocaleDateString('en-CA'))"
2026-08-11

Adding T00:00:00 to a date-only string flips the rule. That is the whole bug, and it is in the spec, not in your parser.

The problem

A row comes back from the database with due_date as '2026-08-12'. You render it:

new Date('2026-08-12').toLocaleDateString()

Everyone in Europe sees the 12th. Everyone in the Americas sees the 11th. Nothing throws, nothing logs, and the test suite passes because CI runs in UTC.

Here is the full comparison, run on Node v26.2.0 with the system zone set to America/Mexico_City (UTC−6):

date-only ISO     2026-08-12T00:00:00.000Z  local getDate= 11
datetime, no offset 2026-08-12T06:00:00.000Z  local getDate= 12
slash form        2026-08-12T06:00:00.000Z  local getDate= 12

Three strings that a human reads as “the twelfth of August”. Two of them are local midnight. One of them is six hours earlier and lands on the previous day.

Why it happens

ECMA-262 defines a Date Time String Format based on ISO 8601, and it draws the line exactly where nobody expects it:

When the time zone offset is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.

That sentence is the entire behaviour. It exists for backward compatibility — ES5 specified date-only strings as UTC, and enough of the web depended on it that ES6 could not change it, so the local-time rule was applied only to the date-time forms.

InputInterpreted asOn UTC−6 it means
'2026-08-12'UTC midnightAug 11, 18:00 local
'2026-08-12T00:00:00'local midnightAug 12, 00:00 local
'2026-08-12T00:00:00Z'UTC midnightAug 11, 18:00 local
'2026-08-12T00:00:00-06:00'explicit offsetAug 12, 00:00 local
'2026/08/12'not in the spec’s formatimplementation-defined

The last row deserves its own warning. Slash-separated dates are not part of the Date Time String Format at all. The spec permits engines to fall back to any implementation-specific parse, so V8 accepting '2026/08/12' as local time is a V8 decision, not a guarantee. The same applies to '08-12-2026' and everything else your engine happens to tolerate today.

The mirror-image bug shows up on the way out. toISOString() converts to UTC before formatting, so slicing a date out of it re-introduces the shift in the other direction:

new Date(2026, 7, 12).toISOString().slice(0, 10)

East of Greenwich, local midnight on the 12th is still the 11th in UTC, so that slice returns the previous day. Round-tripping a calendar date through a Date is lossy in both directions, and which direction bites you depends on the sign of the reader’s offset.

What to do

If the value is a calendar date, do not put it in a Date. A due date, a birthday, an invoice date and a public holiday have no time and no zone. Modelling them as an instant is the actual mistake; the parsing rule just exposes it.

Node 26 exposes Temporal as a global, and Temporal.PlainDate is a calendar date with no instant attached:

const d = Temporal.PlainDate.from('2026-08-12');
d.day;                      // 12 — in every time zone
d.add({ days: 1 }).toString(); // '2026-08-13'

Browser support is still landing, so check MDN’s compatibility table before you ship it to the front end without a polyfill.

If you must use Date, build it from parts. The multi-argument constructor is local by definition, so there is no format to misread:

const [y, m, d] = '2026-08-12'.split('-').map(Number);
const local = new Date(y, m - 1, d);   // local midnight, every engine

If you are only displaying it, do not parse it at all. For a fixed YYYY-MM-DD coming from an API, formatting the string directly is both correct and faster than constructing a Date to immediately throw away.

If it really is an instant, always send the offset. '2026-08-12T00:00:00Z' and '2026-08-12T00:00:00-06:00' are both unambiguous in every engine. A naive datetime with no offset is the format that silently means different things to different machines.

Caveats

References