JavaScript parses a date as UTC and a date-time as local
new Date("2026-07-23") is UTC, but add a time and it becomes local. The ECMAScript rule behind the split, and how to treat naive timestamps.
If a JavaScript date shows up one day off, or a timestamp is off by exactly your UTC offset, the cause is usually how new Date() parses the string. These two lines look like the same kind of input. They aren't parsed the same way.
new Date("2026-07-23"); // midnight UTC
new Date("2026-07-23T14:16:00"); // 14:16 in whatever zone this machine is in
The first one is read as UTC. The second one is read as local time. Add a time to a date string and JavaScript quietly changes which clock it uses, and nothing in the string tells you that happened.
This rule bit a local dashboard of mine (server timestamps showed up eight hours off, which is my UTC offset), so I went and read the rule itself.
The rule, straight from the spec
ECMAScript defines one string format that every engine must accept. It's a simplified ISO 8601: YYYY-MM-DDTHH:mm:ss.sssZ, where the last part is either a literal Z or an offset like +08:00. The spec calls the short versions (YYYY, YYYY-MM, YYYY-MM-DD) date-only forms, and anything with a T and a time after it a date-time form.
Then, in the section for Date.parse (21.4.3.2 in ES2025), there's this sentence:
When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.
So there are three cases, not two:
- Date-only, no offset: UTC.
- Date-time, no offset: local time.
- Date-time with
Zor+hh:mm: exactly that instant, no guessing.
MDN's Date.parse() page says the same thing with examples, and the format itself is described under Date time string format. The new Date(string) constructor goes through the same parsing, so everything here applies to it too.
The history is a little funny. ES5.1 said an absent offset means Z, for everything. ES2015 flipped it: any string without an offset was local, date-only included. ES2016 moved date-only back to UTC and kept date-time local, which is the split we have now. MDN's Date page is blunt about why: the UTC reading of date-only strings is "due to a historical spec error" that "could not be changed due to web compatibility." So the asymmetry isn't an engine bug.
Watching it happen
I ran the same strings through Node 24 with a few time zones. I switched zones by setting process.env.TZ at the top of the script.
With TZ set to Asia/Manila, here is what toISOString() gives back:
"2026-07-23" 2026-07-23T00:00:00.000Z
"2026-07-23T14:16:00" 2026-07-23T06:16:00.000Z
"2026-07-23T14:16:00Z" 2026-07-23T14:16:00.000Z
"2026-07-23T14:16:00+08:00" 2026-07-23T06:16:00.000Z
The date-only string lands on midnight UTC. The naive date-time gets read as 14:16 Manila time, which is 06:16 UTC. Only the strings with an explicit offset mean the same thing everywhere.
Switch to America/New_York and the naive date-time moves the other way, to 18:16 UTC. The date-only string doesn't move at all, which sounds like good news until you format it for a person. In New York, new Date("2026-07-23").getDate() returns 22, because midnight UTC is still the evening before over there. That's the classic "my date picker shows yesterday" bug, and it's the same rule seen from the other side.
Then TZ=UTC. Each naive string now matches its Z twin, so the naive and explicit-UTC rows can't be told apart. That's the part that worries me most. If your laptop, your CI runner, or your server runs in UTC, this bug can't show up there. It only shows up for people in other zones.
Strings outside the format are a guess
The spec only promises behavior for strings in that exact format. For anything else, Date.parse "may fall back to any implementation-specific heuristics or implementation-specific date formats." MDN adds that engines usually fall back to local time for non-standard strings.
What surprised me is how ordinary some of those non-standard strings are. The format allows exactly three digits of milliseconds. Python's datetime.isoformat() writes six digits of microseconds when there are any, like 2026-10-14T13:52:11.709357. A space instead of the T is outside the format too, and so is +0800 without the colon. Node accepted all of them in my test, and treated the naive ones as local, but that's V8 being generous. I only ran Node, so I can't tell you what every browser does with those.
What to do about it
In that dashboard I ended up with a small guard: if a server timestamp has no Z and no offset, append Z before handing it to new Date(). I don't love it. It's only right because that backend's naive timestamps are always UTC, and it would be wrong for an API that sends local times. It also leans on the same engine leniency I just complained about. Appending Z to a six-digit or space-separated string, or to a date-only one like 2026-07-23Z, still gives you something outside the spec format. Node accepts all of those. That's a weaker claim than "it's correct."
The cleaner fix is upstream: have the server always send an offset, so the browser never has to guess. If you control the API, do that. If you don't, at least decide on purpose what a naive string means, and put that decision in one function instead of trusting new Date() to agree with you.
When you see new Date(someString), ask two things about the string: does it have a time, and does it have an offset? Those two answers decide which clock JavaScript uses, and the code around it never tells you.
- javascript
- timezones