Ch. 1 · JavaScript

JavaScript Date and Timezone Pitfalls

Why new Date('2026-03-08') can show March 7, how DST breaks adding 24 hours, and how to store, compute and display dates in JavaScript safely.

~7 min readintermediateupdated Oct 19, 2026

Date bugs have a particular shape: the tests pass on your laptop, the CI server runs in UTC and passes too, and then a user in Chicago reports that their booking for the 8th shows the 7th. Nothing in the code looks wrong. The problem is that JavaScript’s Date mixes two ideas (an instant in time and a calendar date in some place) and silently converts between them using whatever timezone the runtime happens to be in.

This guide explains what a Date actually holds, walks through the parsing rule behind the off-by-one-day bug, shows why daylight saving time breaks naive arithmetic, and ends with a set of storage and display habits that avoid most of it.

All output below comes from Node 22, run with the TZ environment variable set so the timezone is explicit. You can reproduce any of it with TZ=America/New_York node file.mjs.

What a Date object stores

A Date holds exactly one number: milliseconds since 1 January 1970 at 00:00 UTC. It has no timezone of its own. When you call getDate(), getHours() or toString(), the engine takes that number and converts it to the local timezone of the machine running the code. When you call getUTCDate() or toISOString(), it converts to UTC instead.

So the same Date object can honestly answer “what day is it?” with two different numbers, depending on which getter you call and where the code runs. That’s the root of almost every bug in this article. Keep asking yourself: is this value an instant (a moment that’s the same everywhere, like “the payment cleared at 09:30 UTC”) or a calendar date (a label that people in a place agree on, like “my birthday is 8 March”)?

The parsing rule behind the off-by-one day

Here are two strings that look like they describe the same thing:

// TZ=America/New_York
const a = new Date('2026-03-08');
const b = new Date('2026-03-08T00:00');

console.log(a.toISOString());  // 2026-03-08T00:00:00.000Z
console.log(b.toISOString());  // 2026-03-08T05:00:00.000Z
console.log(a.getDate(), a.toDateString());  // 7 Sat Mar 07 2026
JavaScript

The ECMAScript spec says a date-only ISO string (YYYY-MM-DD) is parsed as UTC midnight, while a date-time string without an offset is parsed as local time. That’s a historical compromise, and it’s surprising, but it’s consistent across modern engines.

Now follow the bug. A form gives you "2026-03-08". You do new Date(value) and get midnight UTC on the 8th. A user in New York is five hours behind UTC, so when you display it with getDate() or toDateString(), the local time is 19:00 on the 7th. The date shown is a day early.

Run the same code with TZ=Asia/Kolkata and getDate() returns 8, because India is ahead of UTC. That’s why the bug hides from developers east of Greenwich and shows up for users in the Americas.

The fix depends on what the value means. A birthday or a due date is a calendar date with no time and no zone, so the safest move is often not to turn it into a Date at all. Keep the string "2026-03-08", compare such strings directly (ISO format sorts correctly as text), and only build a Date when you need arithmetic. If you do need a local Date, construct it from parts: new Date(2026, 2, 8) is local midnight on 8 March.

Months are zero-based, and overflow rolls over

That last line has its own trap: in the numeric constructor, months run from 0 to 11. new Date(2026, 1, 1) is 1 February. Days, hours and minutes are one-based or zero-based as you’d expect, which makes the month even easier to get wrong.

Out-of-range values don’t throw; they roll into the next unit. new Date(2026, 1, 30) is 2 March 2026, because February 2026 has 28 days. The same thing makes setMonth misbehave at the end of a month:

const m = new Date(2026, 0, 31);  // 31 January
m.setMonth(1);                     // "February 31"
console.log(m.toDateString());     // Tue Mar 03 2026
JavaScript

If you’re writing “same day next month” logic for billing, clamp the day to the last day of the target month yourself. Rollover isn’t wrong (it’s how you compute “the 100th day of the year” with new Date(2026, 0, 100)), but it’s never what a billing system wants.

Non-ISO strings are worse. new Date('03/08/2026') is parsed as local time in V8, as 8 March, but the spec leaves non-ISO formats to each engine, and a European reader would expect 3 August. V8 even accepts new Date('2026-02-30') and rolls it to 2 March, while other engines may return Invalid Date. Parse only ISO 8601 strings, or use a library that takes an explicit format.

Daylight saving time and “add one day”

In 2026, US clocks spring forward at 02:00 on Sunday 8 March. That day is 23 hours long in New York. Watch what happens to a meeting set for noon on Saturday:

// TZ=America/New_York
const start = new Date(2026, 2, 7, 12, 0);  // Sat 7 March, 12:00

const plus24 = new Date(start.getTime() + 24 * 60 * 60 * 1000);
const nextDay = new Date(start);
nextDay.setDate(nextDay.getDate() + 1);

console.log(plus24.toString());
// Sun Mar 08 2026 13:00:00 GMT-0400 (Eastern Daylight Time)
console.log(nextDay.toString());
// Sun Mar 08 2026 12:00:00 GMT-0400 (Eastern Daylight Time)
JavaScript

Adding 86,400,000 milliseconds moves you 24 real hours forward, which on that day lands at 13:00 local time. setDate(getDate() + 1) works in calendar terms: same wall-clock time, next day. Both are correct answers to different questions. “Remind me in 24 hours” is elapsed time. “Repeat this meeting every day at noon” is calendar time, and only setDate gets it right.

The same split matters when you count days between two dates. Dividing the millisecond difference by 86,400,000 can give 0.9583 or 1.0417 across a DST change, so either round the result or compute the difference on UTC dates built with Date.UTC, where every day has exactly 24 hours.

Ambiguous and missing local times

DST also creates local times that don’t exist and local times that happen twice. On 8 March, 02:30 in New York never happens. On 1 November 2026, when clocks fall back, 01:30 happens twice:

const t = new Date('2026-11-01T06:30:00Z');
for (const tz of ['America/New_York', 'Europe/London', 'Asia/Kolkata']) {
  console.log(tz, new Intl.DateTimeFormat('en-GB', {
    timeZone: tz, dateStyle: 'medium', timeStyle: 'short',
  }).format(t));
}
// America/New_York 1 Nov 2026, 01:30
// Europe/London 1 Nov 2026, 06:30
// Asia/Kolkata 1 Nov 2026, 12:00
JavaScript

06:30Z displays as 01:30 in New York, and so does 05:30Z, an hour earlier. If you store local wall-clock times without an offset, you can’t tell those two moments apart. That’s the strongest argument for storing instants in UTC.

Habits that prevent most date bugs

These cover the large majority of what goes wrong:

  1. Store instants as UTC ISO strings with a Z. JSON.stringify already does this for Date values: a Date built with Date.UTC(2026, 9, 19, 9, 30) serialises as "2026-10-19T09:30:00.000Z". Databases should use a timestamp type that stores UTC.
  2. Store calendar dates as YYYY-MM-DD strings, not as midnight timestamps. A due date has no time zone, so don’t give it one.
  3. Store the user’s IANA timezone name ("America/Chicago", not "CST" or -6) whenever you need to compute their local calendar, like “send the digest at 08:00 their time”. Offsets change twice a year; zone names don’t.
  4. Format at the edge with Intl.DateTimeFormat and an explicit timeZone. Never build display strings from getHours() and string concatenation.
  5. Run your test suite in at least two timezones. TZ=UTC and TZ=America/Los_Angeles will catch most off-by-one bugs. A single CI run in UTC hides them.

Note

The Temporal API is designed to replace Date, with separate types for instants (Temporal.Instant), calendar dates (Temporal.PlainDate) and zoned times (Temporal.ZonedDateTime). It’s arriving in browsers, but Node 22 doesn’t have it yet (typeof Temporal is 'undefined'), so check support or use the polyfill before relying on it.

Practice problem: the due-date report

Question. A task tracker stores each task’s due date by calling new Date(input.value).toISOString() on an <input type="date"> value and saving the result. A user in Los Angeles says tasks due on the 15th appear on the 14th in the weekly report, but a colleague in Berlin sees them correctly. Explain the bug and fix it.

Answer. A date input’s value is a date-only string like "2026-10-15". Per the parsing rule, new Date("2026-10-15") is midnight UTC on the 15th, which is 17:00 on the 14th in Los Angeles (UTC-7 in October). The report presumably formats with local getters or toLocaleDateString(), so in Los Angeles it shows the 14th. Berlin is ahead of UTC, so the same instant is still the 15th there and the bug is invisible. The value is a calendar date, not an instant, so the fix is to stop converting it: store "2026-10-15" as-is (a DATE column, not a timestamp), compare and group by that string, and display it by splitting the parts or formatting new Date(2026, 9, 15). Existing rows can be repaired by taking the UTC date portion of each stored timestamp, since every one of them was written as UTC midnight. A strong answer also says how you’d catch it next time: run the report tests with TZ=America/Los_Angeles.

Further reading

More in JavaScript

esc