Engineering Diaries · JavaScript
§
JavaScriptBrowser InternalsTemporal

JavaScript Reads "2026-10-06" As UTC And "2026-10-06T00:00" As Local Time. Most Off-By-One Date Bugs Start There.

new Date("2026-10-06") is UTC midnight, new Date("2026-10-06T00:00") is local midnight. Tested in Chromium, Firefox and WebKit across three time zones: where the day slips, why Indian teams rarely see it, "06-10-2026" becoming 10 June, DST arithmetic, and what Temporal fixes.

01 · Two Strings

Six Characters Decide Which Day It Is

Here are two lines of JavaScript. Read them and decide whether they create the same Date.

console
new Date("2026-10-06")
new Date("2026-10-06T00:00")

Most people say yes. Same day, same midnight, the second one is just more explicit. In India, they are five and a half hours apart. In New York, they are four hours apart and do not even fall on the same calendar day. The only place on Earth where they match is wherever the local offset happens to be zero at that moment - and in October, that is not even London.

The first string is a date-only form, and the spec says date-only forms with no offset are UTC. The second is a date-time form, and the spec says date-time forms with no offset are local time. Adding T00:00 does not make the string more precise. It changes which clock the string is measured against.

I work on a payroll and HR platform. In that world, a date is not decoration on a timestamp - it is a pay date, a joining date, the first day of leave, the last working day. A date that is off by one is not a cosmetic bug. It is a wrong payslip. So I have a slightly paranoid relationship with how JavaScript handles dates, and I wanted to move from "I think this is how it works" to "I watched it happen".

So I wrote a small probe, ran it with Playwright against Chromium 147, Firefox 148 and WebKit 26.4, and set the browser time zone to Asia/Kolkata, America/New_York and Europe/London for each. Same code, nine runs. Every result in this article comes from that probe or from a DST test I ran in Node with the TZ variable set.

Adding T00:00 does not make the string more precise. It changes which clock the string is measured against.

This is also a follow-up to my piece on TypeScript types vanishing at runtime. Dates are the place where that problem bites hardest, because JSON has no date type at all - and the conversion back and forth is exactly where days go missing.

02 · The Rule

The Spec Changed Its Mind, Then Split The Difference

This is not a browser bug and it is not V8 being weird. It is written into ECMAScript, and it got there through a bit of history that explains why the rule looks so arbitrary.

ES5 (2009) introduced the ISO-style date string format to the language and said: if the offset is absent, it is "Z". Everything without an offset was UTC. Simple, consistent, and the opposite of what ISO 8601 says, which is that a time with no offset is local time.

ES2015 tried to fix that by aligning with ISO 8601: no offset means local time, for every form. That would have been consistent too. The trouble is that by 2015 a large amount of code on the web was already parsing "2015-06-17" and treating the result as UTC midnight. Changing date-only strings to local time would have shifted every one of those dates for every user west or east of Greenwich.

ES2016 settled on the compromise that we still live with:

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 whole bug. Web compatibility kept date-only strings on UTC. ISO alignment moved date-time strings to local. And the two forms look so similar that nobody reading a code review notices which one they are looking at.

Input stringFormInterpreted as
"2026-10-06"Date-onlyUTC midnight
"2026-10-06T00:00"Date-time, no offsetLocal midnight
"2026-10-06T00:00Z"Date-time, UTCUTC midnight
"2026-10-06T00:00+05:30"Date-time, explicit offsetMidnight IST
"2026/10/06", "Oct 6, 2026"Not the ISO formatImplementation-defined

The last row matters more than it looks. The spec only defines one string format. Anything else falls through to "whatever heuristics the engine has". We will get to what that does to Indian-style dates in a minute.

The only safe strings

If a string carries an explicit offset - Z or +05:30 - every engine agrees on the moment. If it does not, you are relying on the date-only versus date-time rule, and on whoever wrote the string knowing it.

03 · The Probe

Nine Runs, Same Code, Three Different Calendars

Here is what new Date("2026-10-06") and its siblings produced. The ISO column is the actual instant stored inside the Date. The getDate() column is what your UI shows if you render the day of the month in the user's local time, which is what nearly every date formatter does by default.

Time zone"2026-10-06" stored asShown as day"2026-10-06T00:00" stored as
Asia/Kolkata2026-10-06T00:00Z62026-10-05T18:30Z
Europe/London (BST)2026-10-06T00:00Z62026-10-05T23:00Z
America/New_York2026-10-06T00:00Z52026-10-06T04:00Z

Chromium, Firefox and WebKit agreed on every one of these. That is the point: this is not cross-browser flakiness you can blame on Safari. It is specified behaviour, implemented faithfully, and still wrong for what most code means.

Look at the New York row. A date picker stores "2026-10-06", the backend echoes it back, the frontend does new Date(value) and renders it with toLocaleDateString(). A user in New York sees 5 October. Nothing threw. No test failed, because the person who wrote the test was in a time zone ahead of UTC, where the day survives.

That last part is the reason this bug is so durable in Indian teams in particular. IST is UTC+5:30. UTC midnight is 5:30 AM in India - still the same calendar day. So new Date("2026-10-06").getDate() returns 6 on every developer machine in Bengaluru, and the bug ships straight to every user in the Americas.

6
Characters ("T00:00") that switch UTC to local
5h 30m
Daily window where IST's toISOString() says yesterday
47
Hours in "two days" across US DST in March
3 Mar
What Date says 31 Jan + 1 month is

The bug is direction-dependent. West of UTC, date-only strings render as the day before. East of UTC, a different set of operations breaks instead - the ones that go from a local date back to a string. India gets that one every single day.

04 · India's 5:30 Problem

toISOString() Returns Yesterday Until 5:30 Every Morning

This is the most common way I see people "format a date as YYYY-MM-DD":

utils/date.js
const today = new Date().toISOString().slice(0, 10);

It looks harmless. It works in every test. And it is wrong for a slice of every day in India, because toISOString() always prints UTC. At 2:00 AM IST on 6 October it is still 8:30 PM on 5 October in UTC, so the string you get is "2026-10-05". My probe confirmed it: a Date constructed for 2:00 AM local in Asia/Kolkata sliced to the 5th in all three engines.

Midnight is worse, because midnight is what date pickers and "start of day" helpers produce. new Date(2026, 9, 6) - local midnight on the 6th - comes out of toISOString() as 2026-10-05T18:30:00.000Z. Slice it and you have the wrong day, every time, not just in the early hours.

And this is not only an Indian problem. In October, London is on British Summer Time, UTC+1. Local midnight there is 23:00Z on the previous day, and the probe sliced it to the 5th as well. Any team that tested this in London in January, on GMT, would have seen the right answer and moved on.

JSON.stringify does the same thing, quietly

JSON.stringify calls Date.prototype.toJSON, which calls toISOString. So a date-of-birth field that a form library stores as a local-midnight Date goes over the wire like this from an IST browser:

probe output, Asia/Kolkata
JSON.stringify({ dob: new Date(2026, 9, 6) })
// '{"dob":"2026-10-05T18:30:00.000Z"}'

Now it depends on the backend. If it parses that as an instant and converts to IST, you get the 6th back. If it truncates to the first ten characters - which is what an awful lot of code does to "get the date part" - you have just changed someone's birthday. Nothing in that pipeline is technically incorrect. Every function did what its docs say. The bug lives in the gap between them.

The fix that keeps it local

If what you want is the user's local calendar date, build the string from the local getters. It is boring and it is correct:

utils/date.js
export const toLocalISODate = (d) =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;

// Also works, and reads cleaner if you trust it: en-CA formats as YYYY-MM-DD
new Intl.DateTimeFormat("en-CA").format(d); // "2026-10-06" in all nine runs

The en-CA trick is widely used and it held up in every engine and zone I tested. I still prefer the explicit version in shared utilities, because the output of Intl locale formats is not a contract - it is whatever the locale data says this year.

05 · DD-MM-YYYY

"06-10-2026" Is 10 June In Chrome And Invalid In Safari

Here is the one that should worry anyone building for Indian users. We write dates day first. 06-10-2026 is 6 October to every person in this country. So what does JavaScript think it is?

Enginenew Date("06-10-2026") in Asia/Kolkata
Chromium 1472026-06-09T18:30:00.000Z - local midnight, 10 June
Firefox 1482026-06-09T18:30:00.000Z - local midnight, 10 June
WebKit 26.4Invalid Date

Three engines, two different wrong answers. Chromium and Firefox read it American-style, month first. WebKit refuses it. Neither of those is a bug. The string is not in the format the spec defines, so the spec says the engine "may fall back to any implementation-specific heuristics or implementation-specific date formats", and these are the heuristics they chose.

This is worse than the UTC problem, because it is not off by one day. It is off by four months, and only for dates where the day is 12 or under. "25-10-2026" cannot be month 25, so you will see an Invalid Date or a nonsense value on the 25th and assume you have found the bug. You have not. You have found the half of it that is loud.

Where this sneaks in

CSV imports, spreadsheet uploads, dates pasted into a text field, legacy APIs that return "dd-mm-yyyy". Any path where a human-formatted date string reaches new Date() or Date.parse() directly. If it is not ISO 8601, parse it yourself: split it, validate the parts, and build the date from numbers.

The same goes for "2026/10/06" and "Oct 6, 2026". All three engines happened to parse those as local midnight in my probe, which is the behaviour most people would expect. But "happened to" is the operative phrase. It is not specified, and I would not build anything on it that I could not afford to have change.

Off by one day is a bug. Off by four months, only on days 1 to 12, is a bug that hides behind its own error message.

06 · The Date Input

Even The Browser's Own Date Picker Hands You UTC

You might think the safe path is to let the platform do it. Use <input type="date">, and read the value back. The input gives you two ways to do that, and they disagree.

date-input.js
input.value;        // "2026-10-06"          - a plain string, no time zone
input.valueAsDate;  // 2026-10-06T00:00:00Z  - UTC midnight, always

input.valueAsDate.getDate(); // 6 in Kolkata and London, 5 in New York

That is straight from the probe, identical across all three engines. valueAsDate is specified as UTC midnight, so it inherits the exact problem from section three. The browser shows the user 6 October in the picker, and if your code then calls getDate() or formats it with a local formatter, a user in New York sees the 5th - in the same form where they just picked the 6th.

The value string, on the other hand, is exactly what you want. It is a calendar date with no time zone attached, which is what the user actually chose. Most of the time the right move is to keep it as that string all the way to the server.

If you do need a Date object for a calendar widget or a comparison, parse the parts and use the local constructor. Do not hand the string to new Date().

utils/date.js
export const parseISODateLocal = (s) => {
  const [y, m, d] = s.split("-").map(Number);
  return new Date(y, m - 1, d); // local midnight - getDate() is 6 everywhere
};

I checked the round trip - toLocalISODate(parseISODateLocal("2026-10-06")) - in Kolkata and New York, and it came back as "2026-10-06" in both. That pairing is the minimum I would want in any codebase that still uses Date for calendar dates. Real code should also validate the parts, because new Date(2026, 1, 31) does not throw - it quietly becomes 3 March.

07 · Arithmetic

A Day Is Not 86,400,000 Milliseconds

Parsing is only half of it. The other half is the arithmetic people do once they have a Date, and the most common shortcut is treating a day as a fixed number of milliseconds.

TZ=America/New_York node dst.js
const a = new Date(2026, 9, 31);              // Sat 31 Oct, midnight
new Date(a.getTime() + 2 * 864e5);         // Sun 1 Nov, 23:00  (not Mon 2 Nov)

const b = new Date(2026, 9, 31);
b.setDate(b.getDate() + 2);                // Mon 2 Nov, 00:00  (correct)

// "How many days of leave?" across the March change
(new Date(2026, 2, 9) - new Date(2026, 2, 7)) / 36e5;  // 47 hours
Math.floor(47 / 24);                       // 1 day. It was two.

US daylight saving time ended on 1 November 2026 and began on 8 March 2026. On those days, a local day is 25 or 23 hours long. Add 2 * 86400000 to midnight on 31 October and you land at 11 PM on the 1st, one hour short. Format that as a date and it says the 1st, not the 2nd. setDate() works in calendar days, so it gets it right.

The leave calculation is the one that hurts. Seven to nine March is two calendar days. In New York it is 47 hours, so Math.floor(diff / 864e5) gives you 1. Swap to Math.ceil to fix that, and the November case - 49 hours - now gives you 3. There is no rounding choice that is right in both directions. Run the same script with TZ=Asia/Kolkata and everything comes out clean, because India does not observe daylight saving time. Which, again, is why Indian developers do not see it.

Months are worse

Monthly pay cycles, subscription renewals, "same day next month" reminders - they all hit this:

console
const d = new Date(2026, 0, 31);  // 31 January
d.setMonth(d.getMonth() + 1);
toLocalISODate(d);              // "2026-03-03"

Date sets the month to February, keeps day 31, notices February 2026 has 28 days, and overflows into March. "One month after 31 January" becomes 3 March. There is no option to clamp to the end of the month instead. You have to write that yourself, and plenty of code does not - which is exactly the kind of thing that only shows up on a payroll run for someone whose cycle starts on the 31st.

The general ruleDo calendar arithmetic in calendar units - days, months - not in milliseconds. Milliseconds are for durations between instants, like "how long did this request take". "Two days from now" and "next month" are calendar questions, and a timestamp cannot answer them.
08 · The Mental Model

A Date Is Not A Moment

Every bug in this article comes from the same confusion. JavaScript's Date is badly named. It does not hold a date. It holds a single number - milliseconds since 1 January 1970 UTC - which is an instant: one specific point on the global timeline. Every "date" operation on it is really a question: "what does this instant look like in some time zone?"

But a lot of the things we model are not instants at all:

  • Calendar dates - a birthday, a pay date, a public holiday, a leave day. 6 October is 6 October in every time zone. It does not have a time, and it does not have an offset.
  • Instants - when a payment was processed, when a user logged in, when a build finished. These really are points on the timeline, and the same instant is a different wall-clock time in different places.
  • Wall-clock times in a place - "the meeting is at 10 AM in Bengaluru". An instant that only makes sense with its zone attached, and that moves when that zone's rules change.
How a calendar date goes wrong
"2026-10-06" (a date)
new Date() (an instant)
local formatter (5 Oct)

Squeezing a calendar date into a Date forces you to pick an instant to represent it - UTC midnight, local midnight, noon - and then every reader has to know which one you picked. The spec picked UTC midnight for date-only strings. Your form library probably picked local midnight. Your backend might pick something else. That is three different conventions for the same 6 October, and the code works until two of them meet.

So the rule I follow: calendar dates travel as "YYYY-MM-DD" strings, instants travel as ISO strings with an explicit Z or offset, and nothing in between. Store dates in a DATE column, not a timestamp. Keep the string in your frontend state. Convert to a Date only at the edge where a widget needs one, using the local parser from section six.

This is the same lesson as the TypeScript piece. A type annotation that says birthday: Date means nothing after JSON.parse - it is a string at runtime. If your types say string and your docs say "calendar date, YYYY-MM-DD", you are at least telling the truth about what is on the wire.

09 · Temporal

Temporal Fixes This By Refusing To Guess

The real fix has been in the works at TC39 for years. Temporal reached Stage 4 and is shipping. It does not patch Date. It replaces it with separate types for separate concepts, which is exactly the distinction from the last section.

temporal.js (Chromium 147, verified)
const pay = Temporal.PlainDate.from("2026-10-06");   // a date. No time, no zone.
JSON.stringify({ pay });                           // '{"pay":"2026-10-06"}'

Temporal.PlainDate.from("2026-03-07")
  .until(Temporal.PlainDate.from("2026-03-09")).days; // 2, in any zone

Temporal.PlainDate.from("2026-10-31").add({ days: 2 });     // 2026-11-02
Temporal.PlainDate.from("2026-01-31").add({ months: 1 });   // 2026-02-28 (clamped)

Temporal.PlainDate.from("06-10-2026");  // RangeError - no guessing

// Going from a date to an instant forces you to name the zone
pay.toZonedDateTime("Asia/Kolkata").toInstant();  // 2026-10-05T18:30:00Z

Every one of those lines ran in Chromium 147 with the outputs shown, in both Kolkata and New York. Look at what disappeared. The leave count is 2 regardless of DST. Month arithmetic clamps to the end of February instead of overflowing. The Indian-format string is rejected loudly instead of becoming June. And there is no way to turn a PlainDate into an instant without saying which time zone you mean - the guess that caused all of this is simply not available.

One sharp edge worth knowing: you cannot compare Temporal objects with <. I tried, and Chromium throws TypeError: Do not use Temporal.PlainDate.prototype.valueOf. That is deliberate, and it is good. Use Temporal.PlainDate.compare(a, b) or a.equals(b).

Where it runs today

  • Chrome and Edge 144+, Firefox 139+ ship Temporal. My Playwright Chromium 147 and Firefox 148 both had it.
  • Safari has not shipped it. The WebKit 26.4 build in my test returned typeof Temporal === "undefined". Since every iOS browser uses WebKit, that means no iPhone or iPad support either.
  • Node 26 ships Temporal without a flag - in official builds. My Homebrew Node 26.0.0 was compiled with v8_enable_temporal_support set to 0, so Temporal was simply undefined. Feature-detect. Do not version-check.

So for anything user-facing, you still need a polyfill - @js-temporal/polyfill from the champions, or the smaller temporal-polyfill. Both are a real bundle cost, and that is a fair reason to hold off on a small app. On anything that handles pay dates, contracts or leave, I think the cost is easily worth it.

If you cannot adopt Temporal yet

Adopt its model anyway. Keep calendar dates as "YYYY-MM-DD" strings, keep instants as offset-qualified ISO strings, and confine Date to the edges. When you do switch, it becomes a find-and-replace on a handful of utilities, not an audit of every component.

10 · Monday Morning

How I Would Find These In An Existing Codebase

You do not need a rewrite to find most of these. Most of them have a recognisable shape, and a few searches will surface them:

terminal
# date-only strings fed to the Date constructor or parser
rg 'new Date\(\s*["\x27`]\d{4}-\d{2}-\d{2}["\x27`]\s*\)'
rg 'Date\.parse\('

# "format as YYYY-MM-DD" via UTC
rg 'toISOString\(\)\.(slice|substring|substr|split)'

# day arithmetic in milliseconds
rg '864e5|86400000|1000 \* 60 \* 60 \* 24|24 \* 60 \* 60 \* 1000'

# the date input's UTC getter
rg 'valueAsDate'

Not every hit is a bug. toISOString().slice(0, 10) on a value that really is a UTC instant, where you really do want the UTC date, is fine. But every hit deserves the question: "is this a calendar date or an instant, and does this line know which?"

Make your tests live somewhere else

The deeper fix is to stop running your test suite in the one time zone where these bugs cannot happen. Node respects the TZ environment variable, so this is cheap:

package.json
"scripts": {
  "test": "vitest run",
  "test:tz": "TZ=America/New_York vitest run && TZ=Pacific/Kiritimati vitest run"
}

New York catches the date-only-renders-as-yesterday class and the DST class. Pacific/Kiritimati is UTC+14, which pushes the toISOString class to its extreme - local midnight there is 10 AM the previous day in UTC. Set the variable before the process starts rather than inside a test file, because the engine may cache the zone. For browser tests, Playwright takes a timezoneId option per context, which is exactly what my probe used. One extra project in your Playwright config with a US time zone is a very cheap insurance policy.

If your CI runners are in UTC, which most hosted runners are, be aware that UTC is the one zone where date-only and date-time strings agree. Your CI is, by accident, the best possible environment for hiding this whole category of bug.

11 · The Short Version

Stop Letting Date Guess

  • new Date("2026-10-06") is UTC midnight. new Date("2026-10-06T00:00") is local midnight. That split has been in the spec since ES2016, and every engine I tested follows it.
  • West of UTC, date-only strings render as the previous day. East of UTC - including all of India - toISOString().slice(0, 10) and JSON.stringify turn local midnight into the previous day.
  • Non-ISO strings are implementation-defined. "06-10-2026" is 10 June in Chromium and Firefox and Invalid Date in WebKit. Parse human-formatted dates yourself.
  • input.valueAsDate is UTC midnight. Use input.value and keep it as a string.
  • Do calendar arithmetic in calendar units. 864e5 is wrong across DST, and setMonth overflows 31 January into 3 March.
  • Treat calendar dates and instants as different types. Temporal does this for you in Chrome, Edge and Firefox; Safari still needs a polyfill.
  • Run your tests with TZ=America/New_York at least once. UTC and IST are the two zones most likely to hide these bugs from you.

None of this is exotic. It is an old problem - computers that store instants being asked about calendars - and a thirty-year-old API that never decided which one it was modelling. The spec rule is odd, but it is stable and it is documented. The bugs come from code that does not know which side of the rule it is on.

A calendar date is not a timestamp at midnight. The moment you store it as one, you have to pick a midnight - and so does everyone who reads it.

All parsing, formatting and date-input results were reproduced locally with Playwright 1.59.1 against Chromium 147.0.7727.15, Firefox 148.0 and WebKit 26.4, each run with timezoneId set to Asia/Kolkata, America/New_York and Europe/London on 6 October 2026. DST and month-overflow arithmetic was run in Node 26.0.0 with TZ=America/New_York and TZ=Asia/Kolkata. Temporal snippets were run in the same Chromium build. The ES5, ES2015 and ES2016 parsing rules are from the respective ECMAScript editions' date time string format sections. Temporal browser support is from caniuse and the Chromium "Intent to Ship: Temporal" thread; Safari's status was confirmed only for the Playwright WebKit build tested here, so check a real Safari release before relying on it.