Temporal: the modern way to handle dates in Node.js
JavaScript's Date object has frustrated developers for thirty years: it's mutable, it counts months from zero, it has no real time-zone support, and parsing behaviour that varies between engines. For most of that time the fix was to reach for a library like Moment, Day.js, or date-fns. Not anymore — Temporal is here, and it's built into the platform.
Temporal reached Stage 4 (it's part of ECMAScript 2026) and is enabled by default in Node.js 26, released in May 2026. It's a complete, modern date and time API — and it finally makes correct date handling the easy path.
One object was the problem; a family of types is the fix
Instead of cramming everything into a single Date, Temporal gives you a set of small, immutable types, each for a specific job:
Temporal.Instant— an exact point in time (like a timestamp), independent of any calendar or zone.Temporal.ZonedDateTime— a date and time in a specific time zone; the full, unambiguous type.Temporal.PlainDate— a calendar date with no time and no zone (e.g. a birthday).Temporal.PlainTime— a wall-clock time with no date (e.g. 09:00).Temporal.PlainDateTime— a date and time with no zone.Temporal.PlainYearMonth— a month in a year with no day (e.g. a card expiry,2026-07).Temporal.PlainMonthDay— a recurring day-in-month with no year (e.g. a birthday,07-05).Temporal.Duration— a length of time (e.g. "2 hours, 30 minutes"), used for arithmetic.
Every operation returns a new object, so you never accidentally mutate a date that's shared elsewhere.
Getting the current date and time
// An exact moment in time
Temporal.Now.instant(); // 2026-07-05T13:00:00.123456789Z
// The current date + time in the system time zone
Temporal.Now.zonedDateTimeISO(); // 2026-07-05T15:00:00+02:00[Europe/Vienna]
// Just today's date — no time, no zone
Temporal.Now.plainDateISO(); // 2026-07-05
// The system time zone identifier
Temporal.Now.timeZoneId(); // "Europe/Vienna"Plain dates: no time zone, no surprises
When you only care about a calendar date, PlainDate keeps things simple. Note the readable, one-based fields — month is 7 for July, not 6.
const date = Temporal.PlainDate.from('2026-07-05');
// or build it from parts:
const date2 = Temporal.PlainDate.from({ year: 2026, month: 7, day: 5 });
date.year; // 2026
date.month; // 7 (one-based!)
date.day; // 5
date.dayOfWeek; // 7 (Sunday)
date.daysInMonth; // 31
// Immutable: .with() returns a NEW date, the original is untouched
const firstOfMonth = date.with({ day: 1 }); // 2026-07-01
date.toString(); // still "2026-07-05"Date math that actually makes sense
Arithmetic takes a plain object of units. Differences between two dates come back as a Temporal.Duration, which you can read field-by-field or reduce to a single unit with .total().
const date = Temporal.PlainDate.from('2026-07-05');
const later = date.add({ months: 1, days: 10 }); // 2026-08-15
const earlier = date.subtract({ weeks: 2 }); // 2026-06-21
// Difference between two dates -> a Temporal.Duration
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-07-05');
const diff = start.until(end, { largestUnit: 'day' });
diff.days; // 185
// A Duration reduces to a single unit with .total()
const meeting = Temporal.Duration.from({ hours: 2, minutes: 30 });
meeting.total({ unit: 'minutes' }); // 150Rounding and balancing durations
A Duration can hold any amount in any unit — 150 minutes is valid and stays as-is until you ask it to normalise. .round() both balances overflow into larger units and rounds to a chosen precision. Use .since() when you want the difference the other way round from .until().
// Balance minutes up into hours+minutes
Temporal.Duration.from({ minutes: 150 })
.round({ largestUnit: 'hour' }).toString(); // "PT2H30M"
// Round to the nearest whole hour
Temporal.Duration.from({ hours: 2, minutes: 40 })
.round({ smallestUnit: 'hour' }).toString(); // "PT3H"
// since() is until() reversed — here as an ISO-8601 duration string
Temporal.PlainDate.from('2026-07-05')
.since('2026-01-01', { largestUnit: 'month' }).toString(); // "P6M4D"Months and recurring dates: PlainYearMonth & PlainMonthDay
Two smaller types cover cases Date never modelled cleanly. PlainYearMonth is a month with no day — perfect for a card expiry or a billing period, and it knows how many days that month has. PlainMonthDay is a day-in-month with no year — a birthday or anniversary — that you project onto a specific year when you need a real date. Note how a Feb-29 anniversary safely lands on Feb 28 in a non-leap year.
const expiry = Temporal.PlainYearMonth.from('2026-02');
expiry.daysInMonth; // 28
expiry.add({ months: 1 }).toString(); // "2026-03"
const birthday = Temporal.PlainMonthDay.from('02-29');
birthday.toPlainDate({ year: 2027 }).toString(); // "2027-02-28" (2027 isn't a leap year)Time zones and DST, handled for you
This is where Date falls apart and Temporal shines. A ZonedDateTime carries its zone in the string itself — the [Area/City] suffix — and arithmetic is daylight-saving aware.
// A specific wall-clock time in a specific zone
const meeting = Temporal.ZonedDateTime.from('2026-07-05T10:00[America/New_York]');
meeting.timeZoneId; // "America/New_York"
meeting.hour; // 10
// Convert to another zone — same instant, different wall clock
const vienna = meeting.withTimeZone('Europe/Vienna');
vienna.hour; // 16
// Adding "1 day" respects clock changes across a DST boundary
const nextDay = meeting.add({ days: 1 });Here's the payoff, made concrete. On 8 March 2026 the US "springs forward" — the clock jumps from 02:00 straight to 03:00, so 02:30 never exists. Watch Temporal handle it: adding one hour to 01:30 lands on 03:30, and that calendar day is only 23 hours long. Getting this right with plain Date is a well-known source of off-by-one-hour bugs.
const before = Temporal.ZonedDateTime.from('2026-03-08T01:30[America/New_York]');
before.add({ hours: 1 }).toString();
// "2026-03-08T03:30:00-04:00[America/New_York]" — skipped the 2 a.m. hour
const dayStart = Temporal.ZonedDateTime.from('2026-03-08T00:00[America/New_York]');
dayStart.until(dayStart.add({ days: 1 }), { largestUnit: 'hour' }).hours; // 23Comparing and sorting dates
const a = Temporal.PlainDate.from('2026-07-05');
const b = Temporal.PlainDate.from('2026-12-25');
Temporal.PlainDate.compare(a, b); // -1 (a is before b)
a.equals(b); // false
// compare() is a ready-made sort comparator
const dates = [b, a];
dates.sort(Temporal.PlainDate.compare); // [a, b]Formatting for humans
Temporal objects work directly with Intl.DateTimeFormat and have a convenient toLocaleString().
const zdt = Temporal.Now.zonedDateTimeISO();
zdt.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'short' });
// "Sunday, July 5, 2026 at 3:00 PM"
new Intl.DateTimeFormat('de-DE', { dateStyle: 'long' }).format(zdt.toPlainDate());
// "5. Juli 2026"Working with existing Date values
You rarely start from scratch — you have Date objects from databases, APIs, and older libraries. Temporal bridges both ways.
// Legacy Date -> Temporal
const instant = new Date().toTemporalInstant();
const zdt = instant.toZonedDateTimeISO('Europe/Vienna');
const today = zdt.toPlainDate();
// Temporal -> legacy Date (for an API that still expects one)
const back = new Date(instant.epochMilliseconds);A practical example: calculating age
"How old is someone born on this date?" is a classic that's annoyingly easy to get wrong with Date (leap years, timezone drift). With Temporal it's one call — take the difference with largestUnit: 'year' and read .years.
function ageInYears(isoBirthday, today = Temporal.Now.plainDateISO()) {
return Temporal.PlainDate.from(isoBirthday)
.until(today, { largestUnit: 'year' })
.years;
}
ageInYears('1990-03-14', Temporal.PlainDate.from('2026-07-05')); // 36Other calendars, for free
Because Temporal separates the calendar from the instant, you can view any date in a non-Gregorian calendar system with .withCalendar() — Hebrew, Islamic, Japanese, Buddhist, and more. This is something Date could never do.
const d = Temporal.PlainDate.from('2026-07-05').withCalendar('hebrew');
d.year; // 5786
d.toString(); // "2026-07-05[u-ca=hebrew]"Proof: it really runs on Node.js 26
Every example above was run on a stock node:26-slim Docker image — no flags, no polyfill. Here's an actual session (with TZ=Europe/Vienna so the wall-clock output is deterministic):
$ docker run --rm -e TZ=Europe/Vienna node:26-slim node -v
v26.4.0
$ node demo.mjs
month (one-based): 7
add 1mo 10d: 2026-08-15
days until Jul 5: 185
NY 10:00 in Vienna: 16:00
balance 150min: PT2H30M
Feb 2026 days: 28
age in years: 36
DST +1h (01:30): 2026-03-08T03:30:00-04:00[America/New_York]
hours in DST day: 23
formatted: Sunday, July 5, 2026 at 3:00 PMCan I use it today?
Node.js 26+ ships Temporal enabled by default, so on the server you can use it right now (that's exactly what the run above shows). In browsers it's still limited availability — not yet Baseline, and not shipped in every engine at the time of writing — so for the browser, or for older Node versions, use the official polyfill:
npm i @js-temporal/polyfillimport { Temporal, Intl, toTemporalInstant } from '@js-temporal/polyfill';
// Also enable Date.prototype.toTemporalInstant()
Date.prototype.toTemporalInstant = toTemporalInstant;
const today = Temporal.Now.plainDateISO();Goodbye, date libraries
Immutable types, first-class time zones, sane arithmetic, and correct comparisons — the exact reasons teams pulled in Moment, Day.js, or date-fns are now covered by the platform itself. For new code, prefer Temporal and drop the dependency; for existing code, migrate gradually using toTemporalInstant() at the boundaries. It's the biggest improvement to dates in JavaScript's history, and it's finally built in.