Developer Tools

JavaScript Dates Are Broken. Here's How to Work with Them Anyway.

The JS Date object is a minefield. Months start at 0, timezone handling is inconsistent, and parsing is unreliable. Here's a battle-tested guide to surviving it.

VR
Vikram Rao

Senior Software Engineer

31 de janeiro de 2026·10 min de leitura

The Date Object: A 1995 Mistake We're Still Living With

JavaScript's Date object was copied from Java 1.0's java.util.Date in 10 days during Netscape's early development. Java deprecated most of that API years ago. JavaScript never did. Every weird behavior you're about to read about traces back to this decision.

Brendan Eich has spoken about this openly. The mandate from Netscape management was to make the language "look like Java," and the Date API was lifted almost verbatim. Java replaced it with Calendar in JDK 1.1 (1997) and then again with java.time in Java 8 (2014). JavaScript is still shipping the original 1995 version to every browser on Earth. That's three decades of accumulated developer frustration.

Month 0: The Classic Gotcha

Months are 0-indexed. January is 0, December is 11. Days are 1-indexed. Years are full years (not years since 1900, unlike the old Java API). This inconsistency has caused more bugs than I can count.

// This creates March 1, not February 1
new Date(2026, 2, 1);  // month 2 = March

// This is what you actually want for February 1
new Date(2026, 1, 1);  // month 1 = February

The fun doesn't stop there. Day overflow is silently accepted: new Date(2026, 1, 30) doesn't throw an error — it rolls forward to March 2 (since February 2026 has 28 days). Some people exploit this for "last day of month" calculations by creating day 0 of the next month: new Date(2026, 2, 0) gives you February 28. Clever? Sure. Readable? Absolutely not. But you'll find it in production code everywhere.

Parsing Is a Trap

Never parse date strings with the Date constructor. The behavior varies across browsers and engines.

// This is UTC in some browsers, local time in others
new Date("2026-03-15");

// This is always UTC (has explicit time + Z)
new Date("2026-03-15T00:00:00Z");

// This is always local time (has time but no Z)
new Date("2026-03-15T00:00:00");

Safari is especially notorious for rejecting formats that Chrome and Firefox accept. new Date("2026-03-15 14:30") (with a space instead of T) returns Invalid Date in Safari but works fine in Chrome. new Date("March 15, 2026") works everywhere, but new Date("15 March 2026") fails in some environments. The spec says parsing non-ISO formats is implementation-defined, which is a polite way of saying "good luck."

Stick to ISO 8601 with explicit timezone indicators, always. If you're receiving date strings from user input or external APIs, parse them with a library or write an explicit parser. Trusting new Date(someString) with user-supplied data is a bug waiting to happen.

getTimezoneOffset() Is Backwards

The method returns the difference in minutes between UTC and local time — but with the sign reversed from what you'd expect.

// In UTC-5 (Eastern Standard Time):
new Date().getTimezoneOffset(); // Returns +300, not -300

The logic is "how many minutes do I add to local time to get UTC?" If you're behind UTC, the offset is positive. This has confused every developer who's ever used it, including me. I've seen codebases with comments like // yes, the sign is intentionally flipped here next to every call to getTimezoneOffset().

There's also a subtle trap: the offset changes depending on the date of the Date object, not the current date. new Date("2026-01-15").getTimezoneOffset() gives you the January offset (standard time), while new Date("2026-07-15").getTimezoneOffset() gives you the July offset (daylight time, if applicable). This is actually useful — it lets you check whether a specific date falls in DST — but it's easy to misuse if you assume the offset is constant.

Intl.DateTimeFormat: The Good Part

The Intl API is genuinely excellent for display formatting. It handles locale-specific formatting, timezone conversion, and even relative time.

// Format for a specific timezone
new Intl.DateTimeFormat("en-US", {
  timeZone: "Asia/Tokyo",
  dateStyle: "full",
  timeStyle: "long",
}).format(new Date());
// "Tuesday, March 18, 2026 at 11:30:00 PM GMT+9"

This is the correct way to display times in other zones. Don't do offset arithmetic manually.

One thing that trips people up: Intl.DateTimeFormat is a formatter, not a converter. It gives you a string, not a Date object. If you need to extract the individual components (hour, minute, etc.) in a target timezone, use formatToParts():

const parts = new Intl.DateTimeFormat("en-US", {
  timeZone: "Asia/Tokyo",
  hour: "numeric", minute: "numeric",
  hour12: false
}).formatToParts(new Date());

const hour = parts.find(p => p.type === "hour").value;
const minute = parts.find(p => p.type === "minute").value;
// Now you have the Tokyo hour and minute as strings

It's verbose, but it's the most reliable way to get timezone-converted values without a library.

Converting Between Timezones

function getTimeInZone(date, timeZone) {
  return new Intl.DateTimeFormat("en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hour12: false,
  }).format(date);
}

console.log(getTimeInZone(new Date(), "Europe/London"));
console.log(getTimeInZone(new Date(), "Asia/Kolkata"));

Calculating Date Differences

function daysBetween(a, b) {
  const msPerDay = 86400000;
  // Use UTC to avoid DST issues
  const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
  const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
  return Math.floor((utcB - utcA) / msPerDay);
}

Always use UTC for date arithmetic. Local time math breaks around DST transitions — a day might be 23 or 25 hours long.

Here's a real-world example of that breakage. On March 8, 2026, the US springs forward. If you compute the difference between March 8 midnight and March 9 midnight in local time, you get 23 hours — not 24. Dividing by msPerDay (86,400,000) gives you 0.9583, which Math.floor rounds down to 0. Your function just told you that March 8 and March 9 are zero days apart. That's why the UTC conversion in the code above is critical — UTC doesn't have DST, so every day is exactly 86,400,000 milliseconds.

Date Mutation: The Silent Killer

The Date object is mutable. Every setter method modifies the original object in place. This leads to bugs that are genuinely hard to track down:

const start = new Date(2026, 2, 15);
const end = start; // NOT a copy — same reference
end.setDate(end.getDate() + 7);

console.log(start.toISOString()); // March 22, not March 15!
console.log(end.toISOString());   // March 22

If you've worked with React state or any pattern where immutability matters, this is a landmine. The fix is to always create a new Date when you need a copy: const end = new Date(start.getTime()). It's boilerplate that shouldn't be necessary, but here we are. The Temporal API (see below) fixes this by making all types immutable — every operation returns a new object.

The Temporal API: The Fix Is Coming

The TC39 Temporal proposal (Stage 3) replaces Date with a proper date/time API. It's immutable, timezone-aware, and has distinct types for different use cases: Temporal.PlainDate, Temporal.ZonedDateTime, Temporal.Instant, and more.

// Temporal (available with polyfill now)
const departure = Temporal.ZonedDateTime.from({
  timeZone: "America/Chicago",
  year: 2026, month: 3, day: 15,
  hour: 14, minute: 30
});

const arrival = departure.add({ hours: 5, minutes: 20 })
  .withTimeZone("Europe/London");

console.log(arrival.toString());
// 2026-03-16T01:50:00+00:00[Europe/London]

The key insight behind Temporal is that "a date" isn't one thing — it's several. Temporal.PlainDate (just a date, no time, no timezone) is different from Temporal.ZonedDateTime (a specific instant in a specific timezone) is different from Temporal.Instant (a point on the global timeline). The old Date object tried to be all of these at once, and it was bad at all of them.

Until Temporal ships natively, the @js-temporal/polyfill package works. For production code today, date-fns or Luxon are solid choices. My personal preference is date-fns for most projects — it's tree-shakeable, uses plain Date objects (so you don't need to learn a new API for basic stuff), and the date-fns-tz addon handles timezone conversion well enough until Temporal arrives.

Frequently Asked Questions

Why is the JavaScript month 0-indexed?

It was copied from Java 1.0's java.util.Date class, which used 0-based months. Java deprecated that API and replaced it with Calendar (and later java.time). JavaScript never did. The Temporal API will fix this — Temporal.PlainDate uses 1-based months.

How do I convert timezones in JavaScript?

Use Intl.DateTimeFormat with the timeZone option. Don't manually add or subtract offset hours — that approach breaks around DST transitions and for zones with non-hour offsets like India (UTC+5:30).

What is the Temporal API?

Temporal is a Stage 3 TC39 proposal to replace the Date object. It provides immutable, timezone-aware types for dates, times, and durations. It's available now via the @js-temporal/polyfill package and is expected to ship in browsers natively in 2026–2027.

Do I still need a date library like date-fns or Luxon?

For now, yes — if you need timezone-aware arithmetic, relative formatting, or business day calculations. Once Temporal is widely available natively, the need for external libraries will drop significantly. For simple formatting, Intl.DateTimeFormat is already sufficient.

How do I get the current date in JavaScript?

Use new Date() to get the current date and time as a Date object. For just the date string in ISO format, use new Date().toISOString().split('T')[0]. For locale-aware formatting, use new Intl.DateTimeFormat('en-US').format(new Date()).

Why does JavaScript Date parsing behave differently across browsers?

The ECMAScript specification leaves date string parsing largely implementation-defined. Only the ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ) is required to be parsed consistently. Other formats like "March 15, 2026" may work in Chrome but fail in Safari. Always use ISO 8601 format for reliable cross-browser parsing.

How do I compare two dates in JavaScript?

Convert both dates to timestamps using getTime() and compare the numbers. For example: dateA.getTime() === dateB.getTime() for equality, or dateA.getTime() < dateB.getTime() for ordering. Never compare Date objects directly with === because that checks object reference, not value.

Sources

  • MDN Web Docs — Date (developer.mozilla.org)
  • TC39 Temporal Proposal (tc39.es/proposal-temporal)
  • Maggie Pint — "Fixing JavaScript Date" (maggiepint.com)

VR

Sobre o Autor

Vikram Rao

Senior Software Engineer

Vikram Rao has been writing timezone-resilient software for fourteen years, building scheduling infrastructure for distributed teams. He has spoken at multiple developer conferences on the surprisingly difficult topic of handling dates and

Ler biografia completa →
Voltar ao Blog

Artigos Relacionados