The Subtle Trap of ISO Date Strings in JavaScript
If you’re building any kind of real application, sooner or later you’ll be wrangling dates and times. And the moment your users live in different time zones, things get… interesting. I’ve hit this wall dozens of times — each encounter requiring a quick mental reset, a bit of digging, and a careful rethink of how to handle it all correctly.
There’s a fantastic (and slightly terrifying) post on the topic — Falsehoods Programmers Believe About Time — that’s absolutely worth a read. But today, I want to zero in on one specific gotcha: how JavaScript parses ISO date strings, and why new Date(str)
and parseISO(str)
from date-fns
might not behave the way you expect.
Where It All Begins
In the project I was reviewing, dates were stored in ISO 8601 format—but in three different ways:
- Full datetime with a trailing
Z
(explicit UTC) - Full datetime without the
Z
(no time zone info) - Date-only strings with no time component
On the client side, both new Date(str)
and parseISO(str)
were used throughout the codebase to parse these values. The result? A mix of behaviors and some surprisingly inconsistent experiences for end users.
Let’s break down each case and figure out a more predictable, less confusing approach.
1. Full ISO datetime with ending Z
This one’s straightforward. Both parseISO(str)
and new Date(str)
do the same thing here: they parse the timestamp as UTC and convert it to the browser’s local time zone. More over it is a part of ISO8601 standard: The ‘Z’ is shorthand for zero-hour offset (+00:00), meaning the time given is aligned exactly with UTC time.
const iso = '2011-07-21T15:00:00Z';
In an EST browser, this becomes:
2011-07-21 10:00 AM (UTC-5)
No surprises here. This is the expected behavior.
2. Full ISO datetime without Z
This is where things could go wrong, but don’t—at least not yet. Both functions interpret the string as local time and keep the value as-is. It is as well by ISO 8601 standard. When the timezone offset or Z is omitted, the datetime is considered local or unspecified—it doesn’t explicitly represent UTC or any other timezone.
const iso = '2011-07-21T15:00:00';
In EST, you get:
2011-07-21 3:00 PM (local time)
There’s no UTC conversion happening here. It’s assumed the string represents a local time, so both parsers comply and don’t shift anything. All good.
3. Date-only strings (no time component)
And here’s the twist. With a plain date like this:
const dateOnly = '2011-11-11';
parseISO(dateOnly)
→ Nov 11, 2011, 12:00 AM local timenew Date(dateOnly)
→ Nov 10, 2011, 7:00 PM EST
Wait—what?
new Date(str)
interprets this string as midnight UTC, then converts it to local time. So if you’re in UTC-5, you get pushed back to the previous day. Meanwhile, parseISO(str)
treats it as local time from the start, which is much more intuitive in most cases and seems to be closer to the ISO 8601 standard. Explicit presence of time zone information is required so the date string is treated not in local time zone.
A Case for Consistency
If you’re working with ISO strings and care about consistent behavior across time zones (and you should), you need to think about both ends: not just how you read dates from storage, but how you write them too.
If your goal is to treat stored datetimes as absolute moments in time, make sure your ISO strings end with a Z. That tells JavaScript (and pretty much every parser) that this value is in UTC—no ambiguity, no surprises.
As for date-only strings? Handle them with care. parseISO(str)
from date-fns
treats them as local time—so 2011-11-11
becomes midnight in the user’s local zone, with no unexpected shifts. new Date(str)
on the other hand interprets them as midnight UTC, which means your “safe” date could silently land on the previous day if your user is west of Greenwich.