new Date('2026-08-12') is UTC midnight, not yours
published
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.
| Input | Interpreted as | On UTC−6 it means |
|---|---|---|
'2026-08-12' | UTC midnight | Aug 11, 18:00 local |
'2026-08-12T00:00:00' | local midnight | Aug 12, 00:00 local |
'2026-08-12T00:00:00Z' | UTC midnight | Aug 11, 18:00 local |
'2026-08-12T00:00:00-06:00' | explicit offset | Aug 12, 00:00 local |
'2026/08/12' | not in the spec’s format | implementation-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
- This is not a Node quirk. The rule is in ECMA-262, so browsers behave the same way for the ISO forms. Only the non-ISO fallbacks vary between engines.
Date.parse('2026-08-12')andDate.parse('2026-08-12T00:00:00Z')return the same number — the date-only form really is UTC, not a formatting artefact.- Setting
TZdoes not always work on Windows. In my testing Node honouredTZ=UTCbut ignoredTZ=Asia/Tokyoand kept the system zone, so verify withIntl.DateTimeFormat().resolvedOptions().timeZonerather than trusting the variable you set. - Libraries do not uniformly fix this — some parse date-only strings as local, others follow the spec. Read your library’s parsing docs instead of assuming; if you use several, they may disagree with each other on the same string.
- A UTC-only backend still hits this the moment a browser formats the value, because the shift happens in the reader’s zone, not on the server.