Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

JavaScript says your date is one day early. Here is why, tested in four time zones

Checked against official sources on October 30, 2026 A user picks November 2. Your app shows November 1. Nobody touched the date, and yet it moved. This one has been around for years. A Stack Overflow question titled "

Checked against official sources on October 30, 2026

A user picks November 2. Your app shows November 1. Nobody touched the date, and yet it moved.

This one has been around for years. A Stack Overflow question titled "Is the Javascript date object always one day off?" was asked in September 2011. When we opened it on October 30, 2026, it had over 380,000 views and a score above 470.

We wrote a tiny repro and ran it with Node v24.22.1 under four time zones: Los Angeles, New York, UTC and Kolkata. All output below is copied from that run.

What is going on, in plain words

A Date in JavaScript is not a calendar day. It is one exact moment in time, stored as milliseconds since 1970 in UTC. UTC is the world's base clock, with no time zone offset.

So when you give it just a day, like "2026-11-02", JavaScript has to pick a moment. It picks midnight UTC.

MDN's Date reference says it directly: when there is no time zone offset, date-only strings are read as UTC, while date-time strings are read as local time. MDN calls this a "historical spec error". In other words, it was a mistake in the rules that can no longer be changed without breaking old sites.

Midnight UTC is still the evening of the day before in the Americas. When you show that moment in local time, you get the earlier date.

The bug, run in four places

const due = "2026-11-02";
const d = new Date(due);
console.log(d.toISOString(), d.getDate(), d.toLocaleDateString("en-US"));
TZ=America/Los_Angeles toISOString: 2026-11-02T00:00:00.000Z  getDate(): 1  shown: 11/1/2026
TZ=America/New_York    toISOString: 2026-11-02T00:00:00.000Z  getDate(): 1  shown: 11/1/2026
TZ=UTC                 toISOString: 2026-11-02T00:00:00.000Z  getDate(): 2  shown: 11/2/2026
TZ=Asia/Kolkata        toISOString: 2026-11-02T00:00:00.000Z  getDate(): 2  shown: 11/2/2026

The stored moment is the same everywhere. Only the shown day changes. In the two US zones, it is one day early.

Notice that Kolkata looks fine here. That is why this bug slips past teams: whoever tests east of UTC sees nothing wrong.

Add a time and the rule flips

Now pass the same day with a time and no Z on the end:

const d = new Date("2026-11-02T00:00:00");
TZ=America/Los_Angeles getDate(): 2  toISOString: 2026-11-02T08:00:00.000Z
TZ=America/New_York    getDate(): 2  toISOString: 2026-11-02T05:00:00.000Z
TZ=UTC                 getDate(): 2  toISOString: 2026-11-02T00:00:00.000Z
TZ=Asia/Kolkata        getDate(): 2  toISOString: 2026-11-01T18:30:00.000Z

This time it is read as local midnight, so the day is right everywhere. Two strings that look almost the same follow two different rules.

The same bug, going the other way

Most people meet the bug when they show a date. Some meet it when they save one. A common habit is to turn a Date into "YYYY-MM-DD" with toISOString().slice(0, 10).

const picked = new Date(2026, 10, 2); // months start at 0, so 10 is November
console.log(picked.toISOString().slice(0, 10));
TZ=America/Los_Angeles saved as: 2026-11-02
TZ=America/New_York    saved as: 2026-11-02
TZ=UTC                 saved as: 2026-11-02
TZ=Asia/Kolkata        saved as: 2026-11-01

Now the US zones are fine, and India is a day early. Local midnight in Kolkata is 18:30 the evening before in UTC, and toISOString() always speaks UTC.

So each half of the bug hides from a different team. Put both halves in one app and you can lose a day on the way in and on the way out.

The fix we use: treat a day as a day

If the value is a plain calendar day, like a birthday, a due date, or a start date, do not let it become a UTC moment. Split the string and build the date from its parts. The new Date(year, monthIndex, day) form always uses local time.

function parseDay(s) {            // "2026-11-02" -> local midnight
  const [y, m, d] = s.split("-").map(Number);
  return new Date(y, m - 1, d);
}

function formatDay(date) {        // local Date -> "2026-11-02"
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  return `${y}-${m}-${d}`;
}
TZ=America/Los_Angeles parseDay: 11/2/2026  round trip: 2026-11-02  UTC display: 11/2/2026
TZ=America/New_York    parseDay: 11/2/2026  round trip: 2026-11-02  UTC display: 11/2/2026
TZ=UTC                 parseDay: 11/2/2026  round trip: 2026-11-02  UTC display: 11/2/2026
TZ=Asia/Kolkata        parseDay: 11/2/2026  round trip: 2026-11-02  UTC display: 11/2/2026

Same day in all four zones, both ways.

The "UTC display" column is the other common fix. Keep new Date("2026-11-02"), but show it in UTC too, so both sides agree:

new Date("2026-11-02").toLocaleDateString("en-US", { timeZone: "UTC" });

That works, but you have to remember timeZone: "UTC" on every display call, and the getUTCDate() family on every read. Miss one, and the bug is back. We find the parse-into-parts version harder to get wrong.

Bonus trap: "add one day" is not "add 24 hours"

While a date is a moment, adding a day by adding milliseconds is risky. Most US clocks fall back one hour on Sunday, November 1, 2026. Look:

const start = new Date(2026, 10, 1);
const plus24h = new Date(start.getTime() + 24 * 60 * 60 * 1000);
const nextDay = new Date(start); nextDay.setDate(start.getDate() + 1);
TZ=America/Los_Angeles +24h: Sun Nov 01 2026 23:00:00  setDate+1: Mon Nov 02 2026 00:00:00
TZ=America/New_York    +24h: Sun Nov 01 2026 23:00:00  setDate+1: Mon Nov 02 2026 00:00:00
TZ=UTC                 +24h: Mon Nov 02 2026 00:00:00  setDate+1: Mon Nov 02 2026 00:00:00
TZ=Asia/Kolkata        +24h: Mon Nov 02 2026 00:00:00  setDate+1: Mon Nov 02 2026 00:00:00

In Los Angeles and New York, that Sunday had 25 hours. Adding 24 hours left us at 11 PM on the same day. setDate(getDate() + 1) moves by one calendar day, so it landed on Monday.

Where this is going: Temporal

MDN lists Temporal as the newer date API. It has a type called PlainDate that is just a day, with no time and no zone, which is exactly what a due date is. In Node v24.22.1 it is still behind a flag, so we ran it with node --harmony-temporal:

const day = Temporal.PlainDate.from("2026-11-02");
console.log(day.toString(), day.day, day.add({ days: 1 }).toString());
TZ=America/Los_Angeles PlainDate: 2026-11-02  day: 2  next: 2026-11-03
TZ=Asia/Kolkata        PlainDate: 2026-11-02  day: 2  next: 2026-11-03

Check support in your own runtime and browsers before you ship it.

Quick checks for your codebase

  • Search for new Date(" with a date-only string. Each one is a suspect.
  • Search for toISOString().slice(0, 10) and .split("T")[0]. Each one is a suspect too.
  • Run your tests twice, with TZ=America/Los_Angeles and TZ=Asia/Kolkata. One zone west of UTC and one east of it catches both halves.
  • Decide, per field, whether it is a calendar day or an exact moment. Store days as "YYYY-MM-DD" text and moments as full timestamps with an offset.

Docs and threads we opened

About TalentOp Systems LLC

Our team works out of Omaha, Nebraska, and we have been in business since February 2025. We do IT staffing, software consulting and project development, and we staff software roles for clients in banking, insurance, healthcare and retail, where one wrong day on a due date or a policy start date is a real problem. Roles can be W2 or C2C, and many run 12+ months.

TalentOp Systems is a direct vendor for Kroger, whose offices are in Blue Ash, Ohio.
E-Verify: yes, TalentOp Systems LLC is enrolled and E-Verified.
Need H-1B sponsorship? TalentOp Systems LLC provides it for hires who meet the role's requirements and eligibility.
We also take H-1B transfers, and OPT or STEM OPT candidates are welcome to reach out.

Find us at www.talentopsystems.com or email [email protected].

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.