The JavaScript Temporal API Is Here — Stop Using Date Objects
Why You Should Care About Temporal Right Now
If you’ve ever written new Date() in JavaScript and then spent the next hour debugging why your month was off by one, your timezone conversion was wrong, or your date arithmetic silently produced garbage — you already know why the Temporal API exists.
Temporal reached Stage 4 in March 2026 and is now part of the ECMAScript standard. Chrome 144+, Edge 144+, and Firefox 139+ ship it natively. Safari is still catching up, but a polyfill covers that gap. This isn’t a proposal anymore. It’s real, it’s shipping, and it fixes nearly every pain point the Date object has inflicted on developers for the past two decades.
This article walks through the core Temporal types, shows you how to actually use them with working code, and highlights the gotchas that will trip you up during migration.
What Was Wrong With Date, Exactly?
Before we get into Temporal, it helps to understand why the old Date object was so broken. Not “slightly inconvenient” — fundamentally broken in ways that caused real production bugs.
Zero-Indexed Months
new Date(2026, 0, 1) gives you January 1st. new Date(2026, 1, 1) gives you February. This has caused more off-by-one bugs than probably any other API decision in JavaScript’s history. Every developer learns this the hard way, usually in production.
Mutability
The Date object is mutable. Call setMonth() on a date, and you’ve changed the original object. Pass a date into a function, and that function can silently modify it. This leads to an entire class of bugs where dates change out from under you.
// This is a real footgun
const startDate = new Date('2026-03-15');
const endDate = startDate; // Not a copy — same reference
endDate.setDate(endDate.getDate() + 7);
console.log(startDate.toISOString()); // Also moved by 7 days. Oops.Time Zone Chaos
Date objects internally store UTC timestamps but display in the local time zone of the runtime environment. There’s no way to say “this date is in America/New_York” — you get whatever the server or browser thinks the local zone is. For any application dealing with users across time zones, this is a nightmare.
No Duration or Interval Type
Want to represent “3 months and 2 days”? Date has no concept of this. You end up doing manual millisecond arithmetic, which breaks across DST transitions and leap seconds.
The Temporal Type System
Temporal doesn’t give you one object that tries to do everything. Instead, it provides distinct types for distinct concepts. This is the single most important design decision in the entire API — pick the right type for your use case, and most of your problems disappear.
| Type | What It Represents | Use Case |
|---|---|---|
Temporal.PlainDate | A calendar date (year, month, day) | Birthdays, holidays, due dates |
Temporal.PlainTime | A wall-clock time (hour, minute, second) | Store opening hours, alarm times |
Temporal.PlainDateTime | Date + time, no time zone | Calendar event drafts, local scheduling |
Temporal.ZonedDateTime | Date + time + time zone | Meeting scheduling across regions |
Temporal.Instant | An exact moment on the UTC timeline | Timestamps, logging, audit trails |
Temporal.Duration | A length of time | “3 hours 45 minutes”, countdown timers |
The key insight: a birthday is a PlainDate. A meeting in Tokyo at 3pm is a ZonedDateTime. The moment a user clicked a button is an Instant. These are genuinely different concepts, and conflating them — which Date forced you to do — is where bugs come from.
Working With Temporal.PlainDate
PlainDate is what you reach for when you care about the calendar date and nothing else. No time component, no time zone. Think of it as “March 15, 2026” — not a specific moment in time, but a day on the calendar.
// Creating PlainDate objects
const birthday = Temporal.PlainDate.from('1995-08-24');
const today = Temporal.Now.plainDateISO();
console.log(birthday.year); // 1995
console.log(birthday.month); // 8 — not 7! Finally, 1-indexed months.
console.log(birthday.day); // 24
// Date arithmetic returns a new object (immutable!)
const nextWeek = today.add({ days: 7 });
console.log(today.toString()); // unchanged
console.log(nextWeek.toString()); // 7 days later
// How far apart are two dates?
const untilBirthday = today.until(birthday.with({ year: today.year }), {
largestUnit: 'day'
});
console.log(untilBirthday.toString()); // e.g., "P42D" — 42 daysNotice .with() in that last example — it creates a new PlainDate with some fields overridden. So birthday.with({ year: today.year }) gives you this year’s birthday. Clean, readable, no manual month/day juggling.
Working With ZonedDateTime
This is the heavy hitter. If your application schedules meetings, sends notifications at specific local times, or displays events across time zones — ZonedDateTime is what you need.
// A meeting at 2:30 PM Eastern time
const meeting = Temporal.ZonedDateTime.from(
'2026-11-08T14:30:00[America/New_York]'
);
// Convert to the attendee's time zone
const meetingInTokyo = meeting.withTimeZone('Asia/Tokyo');
const meetingInLondon = meeting.withTimeZone('Europe/London');
console.log(meetingInTokyo.toString());
// 2026-11-09T04:30:00+09:00[Asia/Tokyo]
console.log(meetingInLondon.toString());
// 2026-11-08T19:30:00+00:00[Europe/London]A subtle but critical detail: the November 8th date was chosen deliberately. In the US, daylight saving time ends on November 1st in 2026. Temporal handles this correctly — the UTC offset for America/New_York automatically uses EST (-05:00), not EDT (-04:00). With the old Date object, you’d need a library like moment-timezone to get this right.
DST-Safe Arithmetic
One of the trickiest bugs with the old Date object is arithmetic across DST boundaries. Adding 24 hours to a date doesn’t always give you “tomorrow” — on the spring-forward day, you’d land on the same calendar date but a different time.
// March 8, 2026 — DST spring-forward in the US
const beforeDST = Temporal.ZonedDateTime.from(
'2026-03-08T01:00:00[America/New_York]'
);
// Adding 1 day gives you March 9th at 1:00 AM — correct!
const nextDay = beforeDST.add({ days: 1 });
console.log(nextDay.toString());
// 2026-03-09T01:00:00-04:00[America/New_York]
// Adding 24 hours gives you March 9th at 2:00 AM — also correct,
// but a different result because that day only had 23 hours
const plus24Hours = beforeDST.add({ hours: 24 });
console.log(plus24Hours.toString());
// 2026-03-09T02:00:00-04:00[America/New_York]This distinction between “add 1 day” and “add 24 hours” matters enormously for scheduling applications. Temporal makes it explicit. The old Date object made it impossible to express this distinction at all.
Temporal.Instant and Temporal.Now
Instant represents a single exact point on the UTC timeline — no time zone, no calendar ambiguity. Think of it as the Temporal equivalent of Date.now(), but with nanosecond precision and immutability.
// Get the current instant
const now = Temporal.Now.instant();
console.log(now.toString()); // e.g., "2026-07-16T06:45:00.123456789Z"
// Create from an ISO string
const deployedAt = Temporal.Instant.from('2026-07-15T18:30:00Z');
// How long ago was the deployment?
const elapsed = now.since(deployedAt);
console.log(elapsed.total('hours')); // e.g., 12.25
// Convert an Instant to a ZonedDateTime for display
const deployedLocal = deployedAt.toZonedDateTimeISO('Asia/Kolkata');
console.log(deployedLocal.toString());
// 2026-07-16T00:00:00+05:30[Asia/Kolkata]Use Instant for timestamps in databases, audit logs, and anything where you need “exactly when did this happen” without caring about what time zone the user was in. Convert to ZonedDateTime only when you need to display it to a human.
Temporal.Now Helpers
The Temporal.Now namespace gives you quick access to the current date/time in various formats:
Temporal.Now.instant(); // Current UTC instant
Temporal.Now.plainDateISO(); // Today's date (system time zone)
Temporal.Now.plainTimeISO(); // Current time (system time zone)
Temporal.Now.plainDateTimeISO(); // Current date + time (no zone)
Temporal.Now.zonedDateTimeISO(); // Current date + time + system zoneDurations and Date Arithmetic
Temporal.Duration finally gives JavaScript a proper way to represent spans of time. No more converting everything to milliseconds and doing manual division.
// Create a duration
const sprint = Temporal.Duration.from({ weeks: 2 });
const timeout = Temporal.Duration.from({ hours: 1, minutes: 30 });
// Durations can include mixed units
const subscription = Temporal.Duration.from({
years: 1,
months: 6
});
// Get the total in a specific unit
console.log(timeout.total('minutes')); // 90
console.log(timeout.total('seconds')); // 5400
// Use durations with date arithmetic
const projectStart = Temporal.PlainDate.from('2026-01-15');
const projectEnd = projectStart.add(sprint);
console.log(projectEnd.toString()); // 2026-01-29The relativeTo Requirement
Here’s a gotcha that catches people: if your duration involves calendar units like months or years, many operations require a relativeTo parameter. The reason is straightforward — “1 month” is not a fixed length of time. February has 28 days, March has 31. Temporal refuses to guess.
const d1 = Temporal.Duration.from({ months: 1 });
const d2 = Temporal.Duration.from({ days: 30 });
// This throws a RangeError — can't compare months to days
// without knowing WHICH month you're talking about
try {
Temporal.Duration.compare(d1, d2);
} catch (e) {
console.log(e.message); // relativeTo is required
}
// Provide a reference date and it works
const result = Temporal.Duration.compare(d1, d2, {
relativeTo: Temporal.PlainDate.from('2026-02-01')
});
console.log(result); // -1 (February has 28 days, so 1 month < 30 days)This is a deliberate design choice. Rather than silently assuming “1 month = 30 days” (which is what most libraries do, and which is wrong), Temporal forces you to be explicit. It’s a bit more typing, but it eliminates an entire class of subtle date math bugs.
Comparing Temporal Objects
With the old Date object, comparing dates required converting to timestamps: date1.getTime() === date2.getTime(). Using == or === directly would compare object references, not values.
Temporal gives you two proper methods:
const a = Temporal.PlainDate.from('2026-07-16');
const b = Temporal.PlainDate.from('2026-07-16');
const c = Temporal.PlainDate.from('2026-12-25');
// .equals() for strict equality
console.log(a.equals(b)); // true
console.log(a.equals(c)); // false
// .compare() for sorting — returns -1, 0, or 1
console.log(Temporal.PlainDate.compare(a, c)); // -1 (a is earlier)
// Perfect for Array.sort()
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']The .compare() method works as a direct comparator function for Array.sort(). No wrapper needed, no arrow function boilerplate. That’s a small thing, but it shows the level of thought that went into the API design.
Common Mistakes When Migrating to Temporal
After working with Temporal across several projects, here are the mistakes I’ve seen developers make most often:
1. Using === to Compare Objects
Old habits die hard. Temporal.PlainDate.from('2026-01-01') === Temporal.PlainDate.from('2026-01-01') is false — they’re different object instances. Always use .equals().
2. Forgetting That .add() Returns a New Object
Because Temporal objects are immutable, date.add({ days: 1 }) does not modify date. You need to capture the return value. Developers used to date.setDate(date.getDate() + 1) forget this constantly.
3. Choosing the Wrong Type
Storing a meeting time as PlainDateTime instead of ZonedDateTime is a bug waiting to happen. If the time zone matters — and for meetings, it always does — use ZonedDateTime. If you’re storing “the user’s birthday,” use PlainDate. Don’t store more information than you actually have.
4. Ignoring the relativeTo Requirement
As shown above, comparing or rounding durations that involve months/years without relativeTo will throw. Don’t catch and suppress this error — fix your code by providing the reference date.
5. Trying to Construct from Individual Numbers Like Date()
new Date(2026, 0, 15) has no direct equivalent. You don’t new Temporal objects — you use .from(). And months are 1-indexed, so it’s Temporal.PlainDate.from({ year: 2026, month: 1, day: 15 }).
Best Practices
- Pick the most specific type. Don’t use
ZonedDateTimewhenPlainDateis sufficient. Extra precision means extra complexity and potential for misuse. - Store timestamps as ISO 8601 strings.
Temporal.InstantandTemporal.ZonedDateTimeboth serialize cleanly to ISO strings. Store those in your database, not epoch milliseconds. - Use the polyfill in production today. Until Safari ships native support,
@js-temporal/polyfillprovides full compatibility. Import it conditionally: checktypeof Temporal !== 'undefined'first. - Don’t mix Temporal and Date in the same codebase. If you’re migrating, create wrapper functions that convert at the boundary (e.g., when reading from a database that returns
Dateobjects). Keep your business logic purely Temporal. - Prefer
.from()with strings over object notation for clarity.Temporal.PlainDate.from('2026-03-15')is harder to misread thanTemporal.PlainDate.from({ year: 2026, month: 3, day: 15 }). - Use
Temporal.Nowinstead ofDate.now(). It returns proper Temporal objects, so you stay in the Temporal ecosystem without converting back and forth.
Wrapping Up
The Temporal API is the biggest improvement to date/time handling in JavaScript since… well, ever. The original Date object was famously designed in 10 days and has been a source of bugs for 30 years. Temporal was designed over 6+ years by the TC39 committee with input from i18n experts, library authors, and production users.
The key takeaways: use distinct types for distinct concepts (PlainDate vs. ZonedDateTime vs. Instant), embrace immutability (always capture return values from .add(), .subtract(), .with()), and remember that months are finally 1-indexed.
If you’re starting a new project, use Temporal from day one. If you’re maintaining an existing codebase, start by replacing Date at the boundaries — API responses, form inputs, database reads — and work inward. The polyfill is stable and the native browser support is already strong.
Dates and times are hard. Temporal doesn’t make them easy — but it makes them correct.
FAQ
Can I use the Temporal API in production right now?
Yes. Chrome 144+, Edge 144+, and Firefox 139+ support it natively. For Safari and older browsers, use the @js-temporal/polyfill package. The API is finalized as part of ECMAScript 2026 — it’s not going to change.
Does Temporal replace Moment.js and date-fns?
For most use cases, yes. Temporal handles time zones, date arithmetic, formatting, and comparisons natively. The main gap is locale-aware formatting — for complex display formatting (e.g., “Thursday, July 16th”), you’ll still want Intl.DateTimeFormat, which pairs naturally with Temporal objects.
How do I convert between Temporal objects and legacy Date objects?
To go from Date to Temporal: Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime()). To go from Temporal to Date: new Date(instant.epochMilliseconds). Keep these conversions at your application boundaries and use Temporal internally.
What’s the performance impact of the polyfill?
The @js-temporal/polyfill adds roughly 25-35 KB gzipped. In environments with native support, it’s zero cost. Use conditional loading: check for globalThis.Temporal before importing the polyfill to avoid unnecessary bundle bloat.
Do I need to learn all the Temporal types at once?
No. Start with Temporal.PlainDate and Temporal.Now for basic date work. Add ZonedDateTime when you need time zones, and Instant for timestamps. Duration comes naturally once you start doing arithmetic. The full API is large, but you’ll use maybe 20% of it in a typical application.
editor's pick
latest video
news via inbox
Nulla turp dis cursus. Integer liberos euismod pretium faucibua

