DEV Community

Vladimir Elchinov for Session Replay

Posted on

Two Date Strings One Character Apart Land on Different Days

A support ticket says the delivery date is showing a day early. You open the page. The date is correct. You check the database. Correct. You ask them to clear their cache, which does nothing, because nothing about this is cached.

They are right and you are right, and the thing standing between you is a rule in the language about when a date string means UTC and when it means wherever the reader happens to be standing.

The rule

A date-only string is parsed as UTC. The same string with a time attached is parsed as local.

That is not a quirk of one engine. It is what the specification asks for, and MDN states the two halves plainly. Of "2019-01-01" it says the string "will imply UTC time because it's date-only". Of "2019-01-01T00:00:00" it says the call "will be set to 2019-01-01 at 00:00:00 in the local timezone of the system, because it has both date and time".

So these two are not the same instant:

new Date("2026-03-01");            // midnight UTC
new Date("2026-03-01T00:00:00");   // midnight where the reader is
Enter fullscreen mode Exit fullscreen mode

Paste both into a console and look at the gap. If you are on UTC the gap is zero, which is most of why this survives code review.

Where the day goes

Midnight is the worst possible instant to be ambiguous about, because it is the boundary. Any negative offset moves a UTC midnight backwards across it.

const d = new Date("2026-03-01");  // UTC
d.toLocaleDateString();            // the first of March, or the last day of February
Enter fullscreen mode Exit fullscreen mode

Which one you get depends entirely on where the browser is. In UTC-5 that date renders as 28 February. The value never changed, the database is fine, your API response is fine, and the pixel on the screen says the wrong day for every visitor west of the meridian.

East of it the mirror case is waiting with any time late enough in the day.

Why it reaches production

Three things have to line up, and they usually do.

The team is in one timezone, often a European one where the offset is small and sometimes zero, so the bug is invisible in development. The test suite runs in CI with TZ unset or set to UTC, where the gap is exactly zero and the assertion passes forever. And the string that triggers it is produced a long way from the code that renders it, usually by a backend serialising a date column, so nobody reading either file sees both halves at once.

Then somebody adds or removes a time component for an unrelated reason, a formatter changes, an API starts returning a full timestamp instead of a date, and the interpretation silently flips for users you cannot see.

What to do instead

Do not hand an ambiguous string to the constructor and hope. Say which one you mean.

// Explicitly UTC, from parts
new Date(Date.UTC(2026, 2, 1));

// Explicitly a fixed instant, with an offset in the string
new Date("2026-03-01T00:00:00Z");

// Rendering a calendar date, where no instant is involved at all
d.toLocaleDateString(undefined, { timeZone: "UTC" });
Enter fullscreen mode Exit fullscreen mode

That last one matters more than it looks. A delivery date, a birthday and an invoice date are not instants. They are labels on a calendar, and converting them to a point on a timeline is the whole mistake. If you never want the reader's clock involved, say so at the formatting end too.

And treat any format outside the spec's as a coin toss. MDN's own note is that the section describing them "contains implementation-specific behavior that may be inconsistent across browsers or different versions of browsers", and the unreliability of Date.parse() is one of the stated motivations for Temporal existing.

The part that makes it expensive

The bug is deterministic. Given the string and the offset, it happens every single time.

But it is deterministic in a variable you do not have. The one fact that decides whether your reader sees the first of March or the twenty-eighth of February is their UTC offset at the moment they loaded the page, and that is not in the ticket, not in your logs, and not something they know to tell you. They will say the date is wrong, which is true, and you will fail to reproduce it, which is also true.

It is the ordinary shape of the reports that take longest: not hard to understand once you see it, and impossible to see from where you are standing.

Top comments (0)