I picked 25 October in a date input, passed its value to new Date(), and printed the result for six time zones. Half of them showed 24 October:
// what a date-only string shows in six time zones
const zones = ["Pacific/Honolulu", "America/Los_Angeles", "America/New_York",
"UTC", "Europe/Berlin", "Asia/Tokyo"];
for (const tz of zones) {
const shown = new Date("2026-10-25").toLocaleDateString("en-US", { timeZone: tz });
console.log(tz.padEnd(20), shown);
}
Pacific/Honolulu 10/24/2026
America/Los_Angeles 10/24/2026
America/New_York 10/24/2026
UTC 10/25/2026
Europe/Berlin 10/25/2026
Asia/Tokyo 10/25/2026
The three wrong ones are the three west of Greenwich. Anyone testing from Europe or Asia sees the right day and ships it.
Then I built the obvious next thing, a filter that lists clips recorded on the picked day, and ran it under four zones. That one returned the wrong 24 hours in every zone except UTC, including Tokyo, where the displayed date had looked fine.
Disclosure: I make an Android camera app. Nothing below uses its code or describes how it stores or names files. The clips here are plain Date objects, one per hour, and every result comes from the scripts shown, run with Node on this machine. Each script ran once per zone with the TZ environment variable set, which is the TZ= prefix on the output lines. Node version, ICU version and kernel:
v22.23.4
78.3
Linux 6.8.0-138-generic
Why a date string turns into the day before
A date input's value is a string like 2026-10-25. Passing it to new Date() uses the same parsing rules as Date.parse(), and MDN's Date page states the rule for strings with no offset: "date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time."
So 2026-10-25 becomes midnight UTC, an instant. In New York that instant is 8 pm on 24 October. Every local getter (getDate(), toLocaleDateString(), toString()) reports the 24th.
Add a time and the rule flips. 2026-10-25T00:00 is read as local midnight:
const a = new Date("2026-10-25"); // date-only form
const b = new Date("2026-10-25T00:00"); // date-time form, no offset
console.log("TZ=" + process.env.TZ);
console.log("date-only getDate()", a.getDate(), " toISOString()", a.toISOString());
console.log("date-time getDate()", b.getDate(), " toISOString()", b.toISOString());
TZ=America/New_York
date-only getDate() 24 toISOString() 2026-10-25T00:00:00.000Z
date-time getDate() 25 toISOString() 2026-10-25T04:00:00.000Z
TZ=UTC
date-only getDate() 25 toISOString() 2026-10-25T00:00:00.000Z
date-time getDate() 25 toISOString() 2026-10-25T00:00:00.000Z
TZ=Asia/Tokyo
date-only getDate() 25 toISOString() 2026-10-25T00:00:00.000Z
date-time getDate() 25 toISOString() 2026-10-24T15:00:00.000Z
Two strings that look like the same day give two different instants. The date-only form puts its midnight in UTC; the date-time form puts it on the local clock. MDN calls the UTC reading "a historical spec error that was not consistent with ISO 8601 but could not be changed due to web compatibility", so this is not going to be fixed under you.
The input's other property has the same trap. valueAsDate hands you a Date already set to midnight UTC, and its MDN page spells out the consequence: "If you are not careful, the result may be off by 1—for example, if the user lives in a negative UTC offset (the US, for example), then interpreting the date as a local date will result in the previous day from what the user selected."
The day filter
Here is the filter. Clips are created at the top of each local hour from the 24th to the 26th, and clipsOn() takes the input's value and keeps anything from that midnight to 24 hours later:
// one clip at the top of every local hour, 24 to 26 October
const clips = [];
for (let day = 24; day <= 26; day++)
for (let h = 0; h < 24; h++) clips.push(new Date(2026, 9, day, h));
const label = d => `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
function clipsOn(value) { // value is what <input type="date"> gives you
const start = new Date(value);
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
return clips.filter(c => c >= start && c < end);
}
const hit = clipsOn("2026-10-25");
console.log("TZ=" + process.env.TZ, "clips:", hit.length,
"first:", label(hit[0]), "last:", label(hit[hit.length - 1]));
TZ=America/New_York clips: 24 first: 10/24 20:00 last: 10/25 19:00
TZ=America/Los_Angeles clips: 24 first: 10/24 17:00 last: 10/25 16:00
TZ=UTC clips: 24 first: 10/25 00:00 last: 10/25 23:00
TZ=Asia/Tokyo clips: 24 first: 10/25 09:00 last: 10/26 08:00
Each zone got 24 clips, so a count check passes. They are the wrong 24.
- New York got four hours of the 24th and lost the last four hours of the 25th.
- Los Angeles got seven hours of the 24th and lost seven of the 25th.
- Tokyo started at 9 am on the 25th and ran into 8 am on the 26th. The label above the list would read 10/25, because Tokyo is east of UTC, so nothing on screen looks off.
- UTC is correct. If your CI runner or server is set to UTC, that is the one zone your tests see.
That last point is why this survives review. A test that builds the expected list in UTC and runs in UTC passes. So does the usual round-trip test, new Date(v).toISOString().slice(0, 10) === v, even in New York, because both halves of it work in UTC and agree with each other while getDate() on the same object says 24. The display bug from the first section only shows up west of Greenwich; the filter bug shows up for anyone not on UTC.
The same trap in the other direction
Going from a Date to a day string has its own version. toISOString().slice(0, 10) is a popular way to get a YYYY-MM-DD key, for a folder name or a query parameter. It gives the UTC day:
// a folder key for "today", made the common way and the local way
const pad = n => String(n).padStart(2, "0");
for (const now of [new Date(2026, 9, 25, 21, 30), new Date(2026, 9, 25, 7, 30)]) {
const iso = now.toISOString().slice(0, 10);
const local = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
console.log("TZ=" + process.env.TZ, `${pad(now.getHours())}:30`,
"toISOString key:", iso, " local key:", local);
}
TZ=America/New_York 21:30 toISOString key: 2026-10-26 local key: 2026-10-25
TZ=America/New_York 07:30 toISOString key: 2026-10-25 local key: 2026-10-25
TZ=UTC 21:30 toISOString key: 2026-10-25 local key: 2026-10-25
TZ=UTC 07:30 toISOString key: 2026-10-25 local key: 2026-10-25
TZ=Asia/Tokyo 21:30 toISOString key: 2026-10-25 local key: 2026-10-25
TZ=Asia/Tokyo 07:30 toISOString key: 2026-10-24 local key: 2026-10-25
At 9:30 pm in New York the key is already the 26th. At 7:30 am in Tokyo it is still the 24th. A job that files clips into a folder per day this way splits one local evening, or one local morning, across two folders.
Two smaller traps on the way to a fix
The obvious fix is to build the date from its parts, which is where the month index comes in. MDN describes getMonth() as returning the month "0 – 11", and the constructor uses the same numbering:
console.log(new Date(2026, 10, 25).toDateString()); // month 10 is November
console.log(new Date(2026, 1, 30).toDateString()); // 30 February rolls over
console.log(new Date("2026-02-30").toISOString()); // so does this string, in V8
Wed Nov 25 2026
Mon Mar 02 2026
2026-03-02T00:00:00.000Z
The first line is the month index: 10 means November. The second and third are worse, because nothing fails. new Date(2026, 1, 30) quietly rolls 30 February forward to 2 March, and in V8 so does the string 2026-02-30. MDN's table of non-standard strings shows Safari returning NaN for the same kind of input (2014-02-30), so the same picker value can give a date in one browser and an invalid date in another. A form field fed by something other than a native picker can reach this code with a day that does not exist.
A version that keeps the day a day
This parser reads the three numbers, builds local midnight from them, and then reads the parts back. If they changed, the input was not a real calendar date and it returns null:
// read "YYYY-MM-DD" as a calendar day in local time, and refuse days that don't exist
function parseLocalDate(value) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!m) return null;
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
const date = new Date(y, mo - 1, d); // month index is zero-based
if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d)
return null; // 2026-02-30 rolled over: reject it
return date;
}
module.exports = { parseLocalDate };
The filter uses it, and takes its end bound from the calendar (the next local midnight) instead of adding 24 hours, since not every local day is 24 hours long:
const { parseLocalDate } = require("./localdate.js");
const clips = [];
for (let day = 24; day <= 26; day++)
for (let h = 0; h < 24; h++) clips.push(new Date(2026, 9, day, h));
const label = d => `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, "0")}:00`;
function clipsOn(value) {
const start = parseLocalDate(value);
if (!start) throw new Error("not a calendar date: " + value);
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1);
return clips.filter(c => c >= start && c < end);
}
const hit = clipsOn("2026-10-25");
console.log("TZ=" + process.env.TZ, "clips:", hit.length,
"first:", label(hit[0]), "last:", label(hit[hit.length - 1]));
for (const v of ["2026-02-30", "2026-2-3"]) {
try { clipsOn(v); } catch (e) { console.log("TZ=" + process.env.TZ, e.message); }
}
TZ=America/New_York clips: 24 first: 10/25 00:00 last: 10/25 23:00
TZ=America/New_York not a calendar date: 2026-02-30
TZ=America/New_York not a calendar date: 2026-2-3
TZ=America/Los_Angeles clips: 24 first: 10/25 00:00 last: 10/25 23:00
TZ=America/Los_Angeles not a calendar date: 2026-02-30
TZ=America/Los_Angeles not a calendar date: 2026-2-3
TZ=UTC clips: 24 first: 10/25 00:00 last: 10/25 23:00
TZ=UTC not a calendar date: 2026-02-30
TZ=UTC not a calendar date: 2026-2-3
TZ=Asia/Tokyo clips: 24 first: 10/25 00:00 last: 10/25 23:00
TZ=Asia/Tokyo not a calendar date: 2026-02-30
TZ=Asia/Tokyo not a calendar date: 2026-2-3
Same 24 clips, 00:00 to 23:00 on the 25th, in all four zones. The rolled-over date and the unpadded string are rejected instead of silently becoming another day.
A shortcut I see often is new Date(value + 'T00:00'). That gets local midnight because it is a date-time form, but it still rolls 30 February forward, so the read-back check is worth keeping either way.
The type built for a calendar day with no time zone is Temporal.PlainDate. MDN's Date.parse() page says "the unreliability of Date.parse() is one of the motivations for the Temporal API to be introduced". The Node 22 build I used does not expose Temporal, so nothing here uses it, and I did not test it.
Checklist
- Treat
valueAsDatefrom a date input as midnight UTC and read it with the UTC getters, or skip it and parsevalueyourself. - Run date tests under at least one zone west of UTC and one east of it, not only the zone of the CI machine.
TZ=America/Los_AngelesandTZ=Asia/Tokyocover both. - Prefer a count-and-bounds assertion (first and last item) over a count alone; the broken filter returned the right count everywhere.
- Build day keys from local getters rather than
toISOString(), unless the day you want really is the UTC day. - Read parsed dates back and reject any whose parts changed.
Things I did not test
- Browsers. Everything ran in Node 22. The parsing rule is from the spec as MDN describes it, but I did not open a page in Chrome, Firefox or Safari.
- Clock-change days. The fixed filter takes its end bound from the calendar for that reason, but I did not build clips across a changeover to check it.
Sources. Quotes are from MDN's pages on the JavaScript Date object, Date.parse() and HTMLInputElement valueAsDate. Outputs are pasted unedited.
If you want an old phone recording clips in the background so there is a day of footage to filter, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.
A date picker hands over a calendar day, and new Date() turns it into an instant somewhere on a clock; each bug above lives in that conversion.
Top comments (0)