A user picks March 1 in a date field. The confirmation line says February 28.
<input type="date" value="2026-03-01">
const d = new Date(input.value);
label.textContent = d.toLocaleDateString(); // "2/28/2026"
Nobody in London ever saw it. Everyone in Chicago saw it constantly.
What is happening
<input type="date"> gives you a date-only string: "2026-03-01". No
time, no zone, because a calendar date does not have either.
new Date() handed a date-only ISO string parses it as UTC midnight —
this is specified, not a quirk. You now hold the instant
2026-03-01T00:00:00Z.
Then toLocaleDateString() renders that instant in the local zone. Six
hours west of UTC, midnight on the 1st is 6pm on the previous day. So it
prints February 28.
Every negative-offset zone — all of the Americas — shows the previous day. Every non-negative zone shows the right one. Which is why this survives code review in one office and generates support tickets from another.
The trap has a sharp edge: adding a time makes it work, for a completely different reason.
new Date("2026-03-01") // UTC midnight → Feb 28 in CST
new Date("2026-03-01T00:00:00") // LOCAL midnight → Mar 1
Same-looking strings, different parsing rules. Discovering that by accident
teaches you the wrong lesson, because appending T00:00:00 looks like
superstition and gets deleted by the next person.
The fix
Do not route a calendar date through an instant at all. Parse the parts:
function parseDateInput(value) {
const [y, m, d] = value.split("-").map(Number);
return { year: y, month: m, day: d }; // no Date, no zone, no instant
}
If you need a Date object — for comparison or arithmetic — build it from
components, which uses local time by construction:
const [y, m, d] = value.split("-").map(Number);
const local = new Date(y, m - 1, d); // local midnight on the right day
And if you are only displaying it, you never needed a Date:
const MONTHS = ["January", "February", "March", /* … */];
const [y, m, d] = value.split("-").map(Number);
label.textContent = `${MONTHS[m - 1]} ${d}, ${y}`;
The rule we keep
A calendar date is not a point in time. “March 1” is not an instant; it is a label on a square. The moment you convert it to an instant you have invented a time and a zone that the user never supplied, and something downstream will convert it back using different assumptions.
Keep it as a string or as three integers until the moment you genuinely need a timestamp — which, for a date the user typed, is usually never.
Testing it
The reason this ships is that developer machines are frequently on UTC, and so is CI. Pin the zone and the test fails on the buggy code everywhere:
process.env.TZ = "America/Chicago";
assert.equal(formatDateInput("2026-03-01"), "March 1, 2026");
Run your date tests in a negative-offset zone. If they only pass in UTC, you do not have date handling — you have a coincidence.