A deep dive into the Temporal API

By

Learn JavaScript Temporal through dates, instants, time zones, DST-safe arithmetic, durations, formatting, storage, testing, and real application workflows.

~~~

JavaScript has had one date and time object since 1995: Date.

It tries to represent many different ideas with one mutable value. A birthday, a local appointment, a UTC timestamp, and the current time all become a Date.

That creates ambiguity.

Does 2026-08-10 mean midnight in Copenhagen, midnight in UTC, or a calendar date with no time zone at all? Is “add one day” the same as “add 24 hours” when daylight saving time changes? Should changing a month mutate the value another function is still using?

The Temporal API fixes the model.

Temporal gives each kind of date and time its own immutable type. You choose what the value means before you manipulate it.

In this tutorial, we will learn that model, use every important type, handle time zones and daylight saving transitions, and build the date workflows I use in real projects.

flowchart LR
  I["Instant<br/>exact point"] --> Z["ZonedDateTime<br/>point + time zone"]
  Z --> D["PlainDate<br/>calendar date"]
  Z --> T["PlainTime<br/>wall-clock time"]
  D --> DT["PlainDateTime<br/>date + time"]
  T --> DT
  DU["Duration<br/>amount of time"] --> D
  DU --> Z

Why Date is difficult

Date is not useless. It stores one number: milliseconds since the Unix epoch. It is widely implemented and accepted by almost every JavaScript library.

The trouble starts when we ask it to act like several different types.

Months are zero-based

This creates January 15:

const date = new Date(2026, 0, 15)

The month is 0, while the day is 15. That convention has produced bugs for decades.

Values are mutable

This changes the original object:

const launch = new Date('2026-08-10T09:00:00Z')
const reminder = launch

reminder.setDate(reminder.getDate() - 1)

launch changed too because both variables reference the same object.

Parsing can hide a time zone conversion

These strings look related, but do not have the same meaning:

new Date('2026-08-10')
new Date('2026-08-10T00:00:00')
new Date('2026-08-10T00:00:00Z')

The first is parsed as UTC. The second uses the system’s local time zone. The third explicitly says UTC.

A time zone is not part of the value

A Date represents an instant. Local getters and formatters project it through a time zone supplied by the environment or formatter.

It cannot represent “09:00 in Europe/Rome, using that zone’s future daylight-saving rules” as one value.

Calendar arithmetic and elapsed time get mixed

Tomorrow at 09:00 is a calendar operation. Exactly 24 hours from now is an elapsed-time operation.

Those answers can differ across a daylight-saving transition.

Temporal makes the distinction explicit.

Temporal reached Stage 4

Temporal reached TC39 Stage 4 in 2025. The current specification draft is being integrated into ECMAScript.

The API provides:

  • immutable date and time objects
  • nanosecond precision
  • first-class IANA time zones
  • calendar-aware arithmetic
  • explicit parsing and serialization
  • types for dates, times, instants, durations, year/month, and month/day values

Temporal does not remove Date. Existing code will keep working.

Start by choosing the meaning

Before writing code, ask what the value represents.

MeaningTemporal typeExample
Exact point on the timelineInstantpayment received at a server
Exact point shown in a named zoneZonedDateTimelivestream starts in Rome
Calendar date without a zonePlainDatebirthday or due date
Wall-clock time without a datePlainTimeshop opens at 09:00
Date and wall-clock time without a zonePlainDateTimeform input awaiting a zone
Year and monthPlainYearMonthcredit-card expiry month
Recurring month and dayPlainMonthDayannual birthday
Amount of time or calendar unitsDuration2 months and 3 days

Most Temporal mistakes begin by choosing a type with the wrong meaning.

Temporal is a namespace

Temporal is a global object, similar to Math.

You do not construct it:

Temporal() // wrong
new Temporal() // wrong

You use the classes and helpers it contains:

const date = Temporal.PlainDate.from('2026-08-10')
const now = Temporal.Now.instant()

Temporal values are immutable

Every operation returns a new value.

const launch = Temporal.PlainDate.from('2026-08-10')
const reminder = launch.subtract({ days: 1 })

launch.toString() // '2026-08-10'
reminder.toString() // '2026-08-09'

The original date cannot change behind your back.

This makes values safer to pass between functions, store in state, and reuse in tests.

Temporal.Now

Temporal.Now reads the host system clock.

Get the current exact instant:

const now = Temporal.Now.instant()

Get today’s ISO date in a specific time zone:

const today = Temporal.Now.plainDateISO('Europe/Copenhagen')

Get the current zoned date and time:

const localNow = Temporal.Now.zonedDateTimeISO(
  'Europe/Copenhagen',
)

Passing the time zone is important on servers. A server might run in UTC while the product’s business day follows Europe/Rome.

Avoid scattering calls to Temporal.Now through business logic. Pass the current value into functions so tests can control it.

Temporal.PlainDate

A PlainDate contains a year, month, and day. It has no clock time and no time zone.

Use it for birthdays, release dates, invoice dates, and calendar deadlines:

const launch = Temporal.PlainDate.from('2026-08-14')

launch.year // 2026
launch.month // 8
launch.day // 14
launch.dayOfWeek // 5

Months are one-based. August is 8.

You can also create a date from fields:

const launch = Temporal.PlainDate.from({
  year: 2026,
  month: 8,
  day: 14,
})

Calendar arithmetic

Adding calendar units is direct:

const start = Temporal.PlainDate.from('2026-08-14')
const nextWeek = start.add({ weeks: 1 })
const previousMonth = start.subtract({ months: 1 })

Temporal handles different month lengths and leap years.

By default, invalid results are constrained. Adding one month to January 31 produces the last valid day of February:

const date = Temporal.PlainDate.from('2027-01-31')

date.add({ months: 1 }).toString() // '2027-02-28'

Ask Temporal to reject overflow when silent adjustment is not acceptable:

Temporal.PlainDate.from(
  { year: 2027, month: 2, day: 31 },
  { overflow: 'reject' },
)

That throws a RangeError.

Temporal.PlainTime

A PlainTime is a wall-clock time without a date or time zone.

const opening = Temporal.PlainTime.from('09:30')

opening.hour // 9
opening.minute // 30

Use it for values such as “the shop opens at 09:30.”

Do not use it for “the job ran at 09:30.” That event happened on a date and timeline.

Temporal.PlainDateTime

A PlainDateTime combines calendar and wall-clock fields without choosing a time zone.

const input = Temporal.PlainDateTime.from(
  '2026-10-25T02:30:00',
)

This matches the value you might receive from a datetime-local form control.

It is not an exact moment yet. Several time zones could map those fields to different instants. During a daylight-saving transition, one zone can even map them to zero or two possible instants.

Choose a time zone before scheduling real work:

const scheduled = input.toZonedDateTime(
  'Europe/Rome',
)

Temporal.Instant

An Instant is one exact point on the UTC timeline.

Use it for server timestamps, audit events, payment times, token expiration, and log entries.

const receivedAt = Temporal.Instant.from(
  '2026-08-10T08:15:30.123456789Z',
)

The Z is required because an instant needs an offset.

You can create one from Unix time:

const instant = Temporal.Instant.fromEpochMilliseconds(
  1786349730123,
)

Read epoch values when an external API needs them:

instant.epochMilliseconds
instant.epochNanoseconds

An instant does not contain a display time zone. Convert it when presenting it:

const rome = instant.toZonedDateTimeISO('Europe/Rome')

Temporal.ZonedDateTime

A ZonedDateTime combines:

  • an exact instant
  • an IANA time-zone identifier
  • a calendar

This is the type for events that occur at a real moment but must follow local clock rules.

const workshop = Temporal.ZonedDateTime.from(
  '2026-08-14T09:00:00+02:00[Europe/Rome]',
)

The string contains both the numeric offset and named time zone.

The offset describes this instant. The zone provides the rule set needed for future and past calculations.

Convert the same instant for another audience:

const newYork = workshop.withTimeZone('America/New_York')

workshop.hour // 9
newYork.hour // 3

The wall-clock fields changed. The instant did not.

flowchart LR
  I["One instant"] --> R["Europe/Rome<br/>09:00"]
  I --> N["America/New_York<br/>03:00"]
  I --> T["Asia/Tokyo<br/>16:00"]

A named time zone is not an offset

+02:00 is an offset. Europe/Rome is a time zone.

The offset says how far local time is from UTC at one moment. The zone contains rules that determine offsets across history and future transitions.

Rome can use +01:00 in winter and +02:00 in summer. A fixed offset cannot model that change.

Store a named zone when future local time matters.

Daylight saving gaps and repeated times

When clocks move forward, some local times do not exist. When clocks move backward, some local times happen twice.

Consider 02:30 during a transition.

Temporal lets you choose a disambiguation policy:

const local = Temporal.PlainDateTime.from(
  '2026-10-25T02:30:00',
)

const earlier = local.toZonedDateTime(
  'Europe/Rome',
  { disambiguation: 'earlier' },
)

const later = local.toZonedDateTime(
  'Europe/Rome',
  { disambiguation: 'later' },
)

The options are:

  • compatible, the default behavior designed for legacy compatibility
  • earlier, choose the earlier possible instant
  • later, choose the later possible instant
  • reject, throw when the local time is ambiguous or missing

For financial cutoffs, bookings, and scheduled jobs, I prefer reject. The application can ask the user to choose instead of silently guessing.

flowchart TD
  L["Local date and time"] --> Q{"How many matching instants?"}
  Q -->|one| O["Use it"]
  Q -->|zero: DST gap| P["Shift or reject"]
  Q -->|two: repeated time| C["Earlier, later, or reject"]

One day is not always 24 hours

This is one of Temporal’s most important lessons.

Start before a daylight-saving change:

const start = Temporal.ZonedDateTime.from(
  '2026-03-28T12:00:00+01:00[Europe/Rome]',
)

Add one calendar day:

const tomorrow = start.add({ days: 1 })

The result is noon on the next calendar day. If the clock moved forward overnight, only 23 elapsed hours passed.

Add 24 exact hours instead:

const later = start.add({ hours: 24 })

That preserves elapsed time, so the local clock can show 13:00.

Use days for calendar schedules. Use hours, minutes, seconds, or instants for elapsed time.

Temporal.Duration

A Duration represents an amount such as “two months and three days” or “90 minutes.”

const trial = Temporal.Duration.from({ days: 14 })
const course = Temporal.Duration.from('P2M3D')
const video = Temporal.Duration.from({ minutes: 90 })

The ISO duration string P2M3D means two months and three days.

A duration is not automatically a fixed number of milliseconds. A month can have 28, 29, 30, or 31 days. A day in a time zone can cross a clock change.

Temporal sometimes needs a starting point to calculate a total:

const duration = Temporal.Duration.from({ months: 1 })

duration.total({
  unit: 'days',
  relativeTo: Temporal.PlainDate.from('2027-02-01'),
}) // 28

Change the starting month and the answer can change.

Difference between dates

Use until() when moving from one value to another:

const start = Temporal.PlainDate.from('2026-08-10')
const end = Temporal.PlainDate.from('2026-09-17')

const difference = start.until(end, {
  largestUnit: 'months',
})

difference.toString() // 'P1M7D'

Use since() for the opposite direction:

end.since(start).days // 38

The options matter. Asking for months uses calendar arithmetic. Asking for days gives a day count.

Round values deliberately

Temporal supports explicit rounding.

const time = Temporal.PlainTime.from('14:37:42')

time.round({
  smallestUnit: 'minute',
  roundingMode: 'halfExpand',
}).toString() // '14:38:00'

You can choose the smallest unit, increment, and rounding mode.

This is safer than dividing milliseconds and hoping calendar behavior does not matter.

Compare values

Temporal objects are not compared by reference with < or >.

Use the static comparison method for the type:

const first = Temporal.PlainDate.from('2026-08-10')
const second = Temporal.PlainDate.from('2026-08-14')

Temporal.PlainDate.compare(first, second) // -1

The result is negative, zero, or positive.

Use .equals() when you need exact equality:

first.equals(second) // false

For ZonedDateTime, compare instants when ordering events. Two zoned values can represent the same instant while displaying different local fields.

PlainYearMonth and PlainMonthDay

Some values are incomplete by design.

Use PlainYearMonth for a billing period or expiry month:

const billingMonth = Temporal.PlainYearMonth.from('2026-08')

billingMonth.daysInMonth // 31

Use PlainMonthDay for a recurring annual date:

const birthday = Temporal.PlainMonthDay.from('08-10')

const thisYear = birthday.toPlainDate({ year: 2026 })

These types prevent fake placeholder values such as “use 1970 for every birthday.”

Parsing strings

Temporal expects standardized, unambiguous strings.

Examples:

Temporal.PlainDate.from('2026-08-10')
Temporal.PlainTime.from('14:30:00')
Temporal.PlainDateTime.from('2026-08-10T14:30:00')
Temporal.Instant.from('2026-08-10T12:30:00Z')
Temporal.ZonedDateTime.from(
  '2026-08-10T14:30:00+02:00[Europe/Rome]',
)

Temporal does not try to interpret human strings such as next Friday, 08/10/26, or tomorrow morning.

That is a feature. Those strings depend on locale, context, and assumptions.

Parse user-facing formats with a dedicated parser, validate the fields, then create the correct Temporal type.

Serialize according to meaning

Every Temporal type has a stable string representation.

const date = Temporal.PlainDate.from('2026-08-10')
date.toString() // '2026-08-10'
date.toJSON() // '2026-08-10'

The right database representation depends on the domain.

Store an instant for events that happened

For audit logs and payments, store an RFC 3339 timestamp or integer epoch value:

2026-08-10T08:15:30.123Z

Store a plain date for date-only facts

For birthdays and publication dates, store:

2026-08-10

Do not turn it into midnight UTC. That invents a time and zone the value never had.

Store local fields and a zone for future schedules

For a recurring 09:00 Rome event, store the intended local time and Europe/Rome. The future UTC offset can change with time-zone rules.

For a one-time event whose instant is already fixed, storing the instant plus display zone can be useful.

flowchart TD
  V["What does the value mean?"] --> H{"Already happened?"}
  H -->|yes| I["Store Instant"]
  H -->|no| Z{"Future local schedule?"}
  Z -->|yes| L["Store local fields + zone"]
  Z -->|no| D{"Date only?"}
  D -->|yes| P["Store PlainDate string"]
  D -->|no| M["Choose the matching Temporal type"]

Format for people

Machine strings are good for storage. People need localized output.

Temporal types provide toLocaleString():

const date = Temporal.PlainDate.from('2026-08-10')

date.toLocaleString('it-IT', {
  dateStyle: 'long',
}) // '10 agosto 2026'

Format a zoned value with date and time styles:

const workshop = Temporal.ZonedDateTime.from(
  '2026-08-14T09:00:00+02:00[Europe/Rome]',
)

workshop.toLocaleString('en-GB', {
  dateStyle: 'full',
  timeStyle: 'short',
})

Keep storage and presentation separate. Do not parse a localized display string back into business data.

Convert between Date and Temporal

Libraries and browser APIs will continue to return Date values.

Convert a Date to an instant:

const legacy = new Date()

const instant = Temporal.Instant.fromEpochMilliseconds(
  legacy.getTime(),
)

Convert an instant back to Date:

const legacy = new Date(instant.epochMilliseconds)

Nanoseconds beyond millisecond precision are lost in that conversion.

A plain date needs a time zone and clock time before it can become an instant:

const date = Temporal.PlainDate.from('2026-08-10')

const zoned = date.toZonedDateTime({
  timeZone: 'Europe/Rome',
  plainTime: Temporal.PlainTime.from('09:00'),
})

const legacy = new Date(zoned.epochMilliseconds)

Notice how the conversion forces us to state the missing assumptions.

Browser and runtime support

Temporal shipped in Firefox 139 in May 2025 and Chrome 144 in January 2026. Node.js 26 enables it by default, and Deno 2.7 exposes it as a stable API.

Safari stable still does not provide the complete API as of August 2026. That means public browser applications still need a polyfill when Safari users are supported.

Feature-detect instead of checking browser versions:

if (globalThis.Temporal) {
  console.log('Native Temporal is available')
}

Use the polyfill correctly

Install the proposal champions’ polyfill:

npm install @js-temporal/polyfill

Import the named export:

import { Temporal } from '@js-temporal/polyfill'

The package does not need to modify the global object. Importing the value makes the dependency explicit and works in current and older runtimes.

If you want to prefer the native implementation and load the polyfill only when required:

const TemporalAPI = globalThis.Temporal ??
  (await import('@js-temporal/polyfill')).Temporal

const today = TemporalAPI.Now.plainDateISO()

Dynamic loading saves the polyfill download in supporting browsers, but it makes application initialization asynchronous. A normal bundled import can be simpler.

Check the polyfill’s bundle cost in your application. Temporal includes substantial calendar and time-zone behavior.

How I use Temporal in my projects

I already expose Temporal examples in several tools on this site.

The date duration calculator shows the difference between legacy Date, date-fns, and Temporal. For adding seven calendar days, the Temporal version expresses the intent directly:

const base = Temporal.PlainDate.from('2026-08-10')
const result = base.add({ days: 7 })

The timestamp converter turns an epoch value into an instant and then a UTC view:

const value = Temporal.Instant
  .fromEpochMilliseconds(1786349730123)
  .toZonedDateTimeISO('UTC')

The JavaScript date formatter generates toLocaleString() examples for Temporal alongside Intl.DateTimeFormat and date-fns.

These tools are good migration boundaries. The underlying calculator can keep accepting Date while the generated recommendation teaches the safer model.

I would use Instant in Sitebase for webhook timestamps, audit events, token expiry, and uptime checks. Those are exact points on a timeline.

I would use PlainDate for analytics day buckets and retention cutoff dates. A reporting day is a calendar fact, not midnight pretending to be an event.

I would use ZonedDateTime for countdown widgets and scheduled broadcasts when the user chooses a named time zone. That removes the current manual conversion between a datetime-local field, the browser offset, and an ISO string.

I would migrate one domain boundary at a time. Replacing every Date call mechanically would hide the decisions Temporal wants us to make.

Where Temporal is not the right tool

Temporal is for date and time domain logic. It is not the answer to every clock.

Measuring elapsed program time

Use performance.now() for request duration, animation timing, and benchmarks. It is monotonic and not affected by wall-clock adjustments.

const start = performance.now()
await runTask()
const elapsed = performance.now() - start

Tiny scripts with broad runtime support

If a script only creates an ISO timestamp and already targets older environments, this remains reasonable:

new Date().toISOString()

Adding a large polyfill for one line might not be worth it.

Libraries that require Date

Keep the boundary conversion until the library supports Temporal. Do not force-cast a Temporal object and hope it behaves like Date.

Natural-language parsing

Temporal does not interpret “next Tuesday at lunch.” Use a dedicated parser, then convert the validated result.

Testing Temporal code

Time-dependent tests fail when business logic reads the real clock directly.

Pass the current value into the function:

function trialEndsAt(startedAt, days = 14) {
  return startedAt.add({ days })
}

const startedAt = Temporal.PlainDate.from('2026-08-10')

trialEndsAt(startedAt).toString() // '2026-08-24'

For a function that needs “now,” provide a default but allow tests to override it:

function isExpired(expiresAt, now = Temporal.Now.instant()) {
  return Temporal.Instant.compare(now, expiresAt) >= 0
}

Test the boundaries that ordinary examples miss:

  • leap day
  • end of month
  • start and end of year
  • daylight-saving gap
  • repeated daylight-saving time
  • negative duration
  • nanosecond rounding
  • invalid input with overflow: 'reject'
  • same instant displayed in different zones

Use named zones in tests. Do not rely on the machine running the suite in your local time zone.

Common mistakes

Using PlainDateTime as a timestamp

It has no time zone or offset. Choose a zone before treating it as an event.

Storing every value as UTC

UTC is right for instants. It is wrong for birthdays, calendar-only deadlines, and future local schedules whose zone rules matter.

Using a fixed offset as a time zone

+02:00 cannot tell you what Rome’s offset will be in January.

Adding 24 hours when you mean tomorrow

Use { days: 1 } on a zoned value for the same local time tomorrow. Use { hours: 24 } for exact elapsed time.

Comparing string output from different types

Use the type’s compare() or equals() methods. Decide whether you are comparing fields or instants.

Parsing localized strings

10/08/2026 is ambiguous across locales. Accept structured fields or an explicit machine format.

Assuming the polyfill installs a global

Import { Temporal } and use that binding, or deliberately assign your own compatibility abstraction.

Calling Temporal.Now everywhere

Clock reads hidden inside business logic make tests unpredictable. Pass time in at the boundary.

A migration strategy

Do not start with a global search and replace.

  1. Inventory what each date value means.
  2. Label it as instant, zoned schedule, plain date, plain time, or duration.
  3. Choose the matching Temporal type.
  4. Define the serialized database format.
  5. Convert at legacy library boundaries.
  6. Add tests for daylight-saving and month-end behavior.
  7. Decide whether native support or a polyfill fits each runtime.
  8. Migrate one workflow at a time.

Start with code where ambiguity already hurts: scheduling, reporting days, expiration logic, time-zone conversion, and calendar arithmetic.

A practical type chooser

flowchart TD
  A["What are you storing?"] --> B{"Exact moment?"}
  B -->|yes| C{"Need named-zone display or arithmetic?"}
  C -->|no| I["Instant"]
  C -->|yes| Z["ZonedDateTime"]
  B -->|no| D{"Date fields?"}
  D -->|date only| P["PlainDate"]
  D -->|date + time| PDT["PlainDateTime"]
  D -->|year + month| YM["PlainYearMonth"]
  D -->|month + day| MD["PlainMonthDay"]
  D -->|time only| PT["PlainTime"]
  D -->|amount| DU["Duration"]

The mental model to keep

Temporal does not give us a nicer version of one Date object.

It gives us a vocabulary.

Use Instant for an exact point. Use ZonedDateTime when that point must follow a named zone. Use PlainDate, PlainTime, and PlainDateTime when the value deliberately has no zone. Use Duration for an amount, and give it context when calendar units need interpretation.

The type should explain the meaning before anyone reads the surrounding code.

That is the real improvement. The methods are nicer, the values are immutable, and daylight-saving arithmetic is safer. But the biggest win is that JavaScript finally makes us say what kind of time we mean.

~~~

Related posts about js: