Every JavaScript developer has a Date war story. The month that was zero-indexed. The timezone that silently shifted a date by a day. The DST transition that turned a two-hour event into a one-hour or three-hour one. The getYear() method that returned 126 instead of 2026. The toString() output that changed depending on the user's system locale.

Date was added to JavaScript in 1995, modelled loosely on the Java java.util.Date class — which Java itself subsequently deprecated in 1997. For nearly three decades, JavaScript shipped this object as-is. Developers worked around it with Moment.js, then date-fns, then Luxon, then Day.js. The underlying API never changed.

The Temporal API is the fix. It shipped as a Stage 4 TC39 proposal and landed in V8 128 (Chrome 128, Node.js 22.5), SpiderMonkey 131 (Firefox 131), and JavaScriptCore in Safari 18 — all released in late 2024 and early 2025. As of 2026, Temporal is baseline across every major browser and runtime. There is no polyfill required for new projects targeting modern environments.

This post covers the core concepts, the type system, practical patterns, and where the old Date object still shows up.

What Was Actually Wrong with Date

Before looking at the solution it helps to understand the problem precisely. Date has several distinct failure modes:

The Temporal Type System

Temporal's central design insight is that different date/time concepts are genuinely different types, not the same type wearing different hats. The main types are:

This sounds like a lot of types, but the selection rule is straightforward: use the least specific type that represents your data correctly. A birthday is a PlainDate. A scheduled notification is a ZonedDateTime. A server log entry is an Instant.

Getting the Current Date and Time

The Temporal.Now namespace provides access to the current moment in each type:

// The current instant (like Date.now(), but nanosecond precision)
const now = Temporal.Now.instant();
console.log(now.toString()); // "2026-05-20T09:32:14.482196544Z"

// The current date in a specific timezone
const today = Temporal.Now.plainDateISO("America/New_York");
console.log(today.toString()); // "2026-05-20"

// The current date+time in a specific timezone
const localNow = Temporal.Now.plainDateTimeISO("Europe/London");
console.log(localNow.toString()); // "2026-05-20T10:32:14.482196544"

// Full ZonedDateTime with timezone attached
const zoned = Temporal.Now.zonedDateTimeISO("Asia/Tokyo");
console.log(zoned.toString()); // "2026-05-20T18:32:14.482196544+09:00[Asia/Tokyo]"

Note: all Temporal types are immutable. None of these objects has a setter method.

Creating Specific Dates

Months are 1-indexed in Temporal. January is 1. December is 12. This alone eliminates an entire class of bugs:

// PlainDate from year, month, day
const birthday = Temporal.PlainDate.from({ year: 1990, month: 6, day: 15 });
console.log(birthday.toString()); // "1990-06-15"

// From ISO string
const release = Temporal.PlainDate.from("2026-09-01");

// PlainTime from components
const meetingTime = Temporal.PlainTime.from({ hour: 14, minute: 30 });
console.log(meetingTime.toString()); // "14:30:00"

// ZonedDateTime from ISO string with timezone
const event = Temporal.ZonedDateTime.from(
  "2026-11-01T09:00:00[America/Chicago]"
);
console.log(event.toString());
// "2026-11-01T09:00:00-05:00[America/Chicago]"
// Note: CDT ended Nov 1 2026 — Temporal knows this automatically

Date Arithmetic

This is where Temporal's design pays off most immediately. Adding and subtracting durations produces correct results across DST transitions, month boundaries, and year boundaries:

const today = Temporal.PlainDate.from("2026-05-20");

// Add 30 days
const in30Days = today.add({ days: 30 });
console.log(in30Days.toString()); // "2026-06-19"

// Add 1 month (calendar-aware: May 31 + 1 month = June 30, not July 1)
const nextMonth = Temporal.PlainDate.from("2026-05-31").add({ months: 1 });
console.log(nextMonth.toString()); // "2026-06-30"

// Subtract
const lastWeek = today.subtract({ weeks: 1 });
console.log(lastWeek.toString()); // "2026-05-13"

// Arithmetic on ZonedDateTime correctly handles DST
// On Nov 1 2026 at 1:59 AM, clocks fall back to 1:00 AM in US/Eastern
const beforeFallback = Temporal.ZonedDateTime.from(
  "2026-11-01T01:30:00[America/New_York]"
);
const twoHoursLater = beforeFallback.add({ hours: 2 });
// Result is 2:30 AM EST (not 3:30 AM as naive ms arithmetic would give)
console.log(twoHoursLater.toLocaleString("en-US", { timeZone: "America/New_York" }));

Comparing Dates and Finding Differences

const start = Temporal.PlainDate.from("2026-01-01");
const end = Temporal.PlainDate.from("2026-05-20");

// Compare: returns -1, 0, or 1
const order = Temporal.PlainDate.compare(start, end); // -1 (start is before end)

// Check equality
console.log(start.equals(end)); // false

// Get the difference as a Duration
const diff = start.until(end);
console.log(diff.toString()); // "P139D" (139 days)

// Get difference in specific units
const diffInMonths = start.until(end, { largestUnit: "month" });
console.log(diffInMonths.months); // 4
console.log(diffInMonths.days);   // 19

// Sort an array of dates
const dates = [
  Temporal.PlainDate.from("2026-12-25"),
  Temporal.PlainDate.from("2026-01-01"),
  Temporal.PlainDate.from("2026-07-04"),
];
dates.sort(Temporal.PlainDate.compare);
console.log(dates.map(d => d.toString()));
// ["2026-01-01", "2026-07-04", "2026-12-25"]

Timezones Done Right

Timezone handling is where Date fails the most silently, and where Temporal's design is clearest. The key distinction is between a wall-clock time (what the clock on the wall reads in a given city) and an absolute time (a specific moment in universal time). These are different things, and confusing them is the root cause of most timezone bugs.

// "9 AM in New York" -- a wall-clock time in a specific zone
const nyMeeting = Temporal.ZonedDateTime.from({
  year: 2026,
  month: 6,
  day: 15,
  hour: 9,
  minute: 0,
  timeZone: "America/New_York",
});

// Convert to another timezone -- the wall clock changes, the moment does not
const londonTime = nyMeeting.withTimeZone("Europe/London");
console.log(londonTime.hour); // 14 (BST = UTC+1, NY in EDT = UTC-4, so +5 hours)

const tokyoTime = nyMeeting.withTimeZone("Asia/Tokyo");
console.log(tokyoTime.hour); // 22 (JST = UTC+9)

// Converting to an Instant strips the timezone to get the absolute moment
const instant = nyMeeting.toInstant();
console.log(instant.toString()); // "2026-06-15T13:00:00Z"

// Reconstructing a ZonedDateTime from an Instant requires providing a timezone
const reconstructed = instant.toZonedDateTimeISO("America/Los_Angeles");
console.log(reconstructed.hour); // 6 (PDT = UTC-7)

A useful rule: store and transmit Instant values (or their ISO string equivalents ending in Z). Display and reason about ZonedDateTime values. Never pass raw PlainDate or PlainDateTime across a timezone boundary without making the timezone explicit.

Working with Durations

Duration is a first-class type in Temporal, not just a number of milliseconds:

// Create a duration
const twoWeeks = Temporal.Duration.from({ weeks: 2 });
const ninetyMinutes = Temporal.Duration.from({ hours: 1, minutes: 30 });

// Add durations
const combined = twoWeeks.add({ days: 3 });
console.log(combined.toString()); // "P2W3D"

// Negate (for subtraction)
const negated = twoWeeks.negated();

// Total in a single unit
const totalDays = Temporal.Duration.from({ years: 1, months: 6 })
  .total({ unit: "days", relativeTo: Temporal.PlainDate.from("2026-01-01") });
console.log(totalDays); // 547 (calendar-aware: 365 + 181 days in the specific half-year)

// Human-readable formatting using Intl.DurationFormat
const formatter = new Intl.DurationFormat("en", { style: "long" });
console.log(formatter.format({ hours: 2, minutes: 45 }));
// "2 hours, 45 minutes"

Formatting for Display

Temporal types integrate with Intl.DateTimeFormat for locale-aware output. You do not need a separate formatting library:

const date = Temporal.PlainDate.from("2026-05-20");

// US English, long format
const usFormatter = new Intl.DateTimeFormat("en-US", {
  dateStyle: "full",
  calendar: "iso8601",
});
// Note: pass the Temporal object directly -- Intl supports Temporal natively
console.log(date.toLocaleString("en-US", { dateStyle: "long" }));
// "May 20, 2026"

console.log(date.toLocaleString("de-DE", { dateStyle: "long" }));
// "20. Mai 2026"

console.log(date.toLocaleString("ja-JP", { dateStyle: "long" }));
// "2026年5月20日"

// ZonedDateTime with time
const event = Temporal.ZonedDateTime.from("2026-05-20T14:30:00[Europe/Paris]");
console.log(event.toLocaleString("fr-FR", {
  dateStyle: "short",
  timeStyle: "short",
}));
// "20/05/2026, 14:30"

Practical Example: Subscription Billing

Here's a realistic scenario that illustrates why the type system matters. Suppose you're computing the next billing date for a monthly subscription:

function getNextBillingDate(lastBilledDate, billingDayOfMonth) {
  // lastBilledDate is a PlainDate -- just a date, no time, no timezone
  const next = lastBilledDate.add({ months: 1 });

  // Clamp to the last day of the month if billing day exceeds month length
  // (e.g. billing on the 31st: Feb billing date = Feb 28/29)
  const lastDayOfMonth = next.with({ day: 1 }).add({ months: 1 }).subtract({ days: 1 }).day;
  const clampedDay = Math.min(billingDayOfMonth, lastDayOfMonth);

  return next.with({ day: clampedDay });
}

const lastBilled = Temporal.PlainDate.from("2026-01-31");
console.log(getNextBillingDate(lastBilled, 31).toString()); // "2026-02-28"

const marchBilling = getNextBillingDate(
  Temporal.PlainDate.from("2026-02-28"), 31
);
console.log(marchBilling.toString()); // "2026-03-31"

With the old Date, this kind of month-end arithmetic required careful manual handling. PlainDate.add({ months: 1 }) handles the clamping automatically: Temporal.PlainDate.from("2026-01-31").add({ months: 1 }) gives 2026-02-28, not 2026-03-03.

Practical Example: Countdown Timer

function timeUntil(targetDate) {
  // Target is a PlainDate -- the event is on that day regardless of timezone
  const today = Temporal.Now.plainDateISO();
  const diff = today.until(targetDate, { largestUnit: "day" });

  if (diff.days < 0) return "This event has passed.";
  if (diff.days === 0) return "Today!";
  if (diff.days === 1) return "Tomorrow!";
  return `${diff.days} days away`;
}

console.log(timeUntil(Temporal.PlainDate.from("2026-12-25"))); // "219 days away"

Practical Example: Scheduling Across Timezones

// Find a time that works for two offices: San Francisco and Berlin
function findOverlap(sfHour, berlinHour, targetDate) {
  const sfTime = Temporal.ZonedDateTime.from({
    ...targetDate.getISOFields(),
    hour: sfHour,
    minute: 0,
    timeZone: "America/Los_Angeles",
  });

  const berlinTime = Temporal.ZonedDateTime.from({
    ...targetDate.getISOFields(),
    hour: berlinHour,
    minute: 0,
    timeZone: "Europe/Berlin",
  });

  // Compare absolute times to see which is earlier
  const sfInstant = sfTime.toInstant();
  const berlinInstant = berlinTime.toInstant();

  return Temporal.Instant.compare(sfInstant, berlinInstant) === 0
    ? "Same moment!"
    : `SF ${sfHour}:00 is ${sfInstant.until(berlinInstant).total("minutes")} minutes ${
        Temporal.Instant.compare(sfInstant, berlinInstant) < 0 ? "before" : "after"
      } Berlin ${berlinHour}:00`;
}

const date = Temporal.PlainDate.from("2026-06-01");
console.log(findOverlap(9, 18, date));
// "Same moment!" -- 9 AM PDT (UTC-7) = 18:00 CEST (UTC+2)

Migration: When You Still Need Date

The old Date object is not going away. APIs that predate Temporal — including most third-party libraries, DOM events, performance.now(), and setTimeout — still use it. You will need to convert between the two in practical code:

// Date to Temporal
const legacyDate = new Date("2026-05-20T10:00:00Z");
const instant = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime());
const zoned = instant.toZonedDateTimeISO("America/New_York");

// Temporal to Date
const temporalInstant = Temporal.Now.instant();
const asDate = new Date(temporalInstant.epochMilliseconds);

// Working with fetch/JSON timestamps
const response = await fetch("/api/events");
const data = await response.json();
// API returns ISO strings -- parse them as Instant, then display in user's zone
const events = data.events.map(e => ({
  ...e,
  timestamp: Temporal.Instant.from(e.timestamp),
}));
const userZone = Temporal.Now.timeZoneId(); // e.g. "America/Chicago"
events.forEach(e => {
  const local = e.timestamp.toZonedDateTimeISO(userZone);
  console.log(local.toLocaleString("en-US", { dateStyle: "short", timeStyle: "short" }));
});

What About Date Libraries?

Moment.js, date-fns, Luxon, and Day.js all solve subsets of the problems that Temporal now solves natively. For new projects targeting modern environments (Node.js 22+, current browsers), you probably do not need any of them.

The exception is calendar systems. Temporal supports non-Gregorian calendars — Hebrew, Islamic, Persian, Japanese, Chinese, and others — through its calendar parameter. If you're building internationally and need to display dates in the user's native calendar, Temporal handles this natively through Intl. If you need heavy-duty recurrence rules (iCalendar RRULE), timezone database management, or relative time formatting beyond Intl.RelativeTimeFormat, a library may still add value. But the core use case — representing, computing, and formatting dates and times correctly — is now covered by the platform.

Browser support as of May 2026: Temporal is available natively in Chrome 128+, Firefox 131+, Safari 18+, and Node.js 22.5+. Edge tracks Chrome. For older targets, the TC39 Temporal polyfill (@js-temporal/polyfill) provides full compatibility down to Node.js 14 and Chrome 80.

The TL;DR

Temporal fixes Date by giving every distinct date/time concept its own type: PlainDate for calendar dates, ZonedDateTime for timezone-aware moments, Instant for absolute timestamps, and Duration for lengths of time. All types are immutable. Months are 1-indexed. Arithmetic is calendar-aware and DST-correct. Timezone conversion is explicit. Parsing is deterministic.

If you have been reaching for a date library by reflex, it is worth pausing on your next project and trying Temporal directly. The surface area is larger than Date, but the model is coherent in a way the old API never was. Once you stop fighting the type system and start using it, date handling stops being the part of the code you dread.