Log Entry

Using Temporal

Feb 17, 2026 · 29 min read

Date handling in JavaScript fails in predictable ways when code is run across real production conditions:

  • Date values are mutable in shared logic paths,
  • local environment timezone leaks into business timestamps,
  • DST transitions and timezone rule changes produce edge cases that surface in operational windows.

This article walks through a practical migration path from Date to Temporal through focused examples.

Each section does three things:

  • 1) names one failure mode,
  • 2) explains why it happens,
  • 3) shows it in a focused dispatch console example.

To keep the examples concrete, the widgets use one fictional scenario: Bob's International Delivery Co.

If you have had production incidents where something was correct in one environment but wrong by an hour in another, this is that class of bug.

The 7 main solves

1) Ambiguous datetimes (the fall-back hour happens twice)

This is the repeated-hour problem.

At DST fall-back, local clock time repeats. A value like 01:30 can map to two different instants.

This is one of the most counterintuitive parts of time handling: the wall clock looks continuous to a human, but the timeline is not one-to-one with local clock labels at transition boundaries. You can have two real moments that both print as 01:30, but each one has a different offset and a different downstream effect.

Because dispatch systems capture civil intent, that ambiguity is operationally significant:

  • a customer asks Bob's team for a handoff at 01:30 in New York,
  • but the clock time alone does not tell you which 01:30.

Pick the wrong one and the truck departs an hour early or late.

Temporal handles this with explicit disambiguation instead of silent defaults.

That explicit choice matters because ambiguity is a business decision, not just a parsing detail. In many systems, the right answer depends on policy: maybe operations want the first occurrence for maintenance windows, while billing wants the second occurrence for end-of-day cutoff logic.

In practice, your booking flow should:

  • capture local civil time + zone,
  • pick the intended repeated instant (earlier or later),
  • persist that policy with the value.
const first0130 = Temporal.ZonedDateTime.from(
  { timeZone: "America/New_York", year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
  { disambiguation: "earlier" }
);

const second0130 = Temporal.ZonedDateTime.from(
  { timeZone: "America/New_York", year: 2026, month: 11, day: 1, hour: 1, minute: 30 },
  { disambiguation: "later" }
);

Ambiguous local times are not a bad input format issue. They are a natural result of historical time-zone transitions.

If your system currently stores only a formatted string or a naive local timestamp, this is exactly where subtle “we were off by one hour” incidents come from. Temporal makes that ambiguity visible at the point where it should be resolved.

Let’s see this in Bob’s console:

Bob's International Delivery

Dispatch Console

LiveOps: LON-02

Dispatch case: repeated 01:30 in New York.

Temporal Playground

Clock replay: watch the fall-back hour repeat and compare Date-style offset logic with Temporal.

2) Impossible datetimes (the spring-forward hour does not exist)

This is the missing-hour problem.

At DST spring-forward, some local times do not exist at all. 02:30 can be impossible in that zone on that day.

This is the mirror image of the repeated-hour case. Instead of “one local label, two instants,” you get “one local label, zero instants.” A user can type a perfectly valid-looking local time that has no real mapping on the timeline.

In Bob's dispatch workflow this shows up quickly: support asks for a shipping cutoff at 01:30, and the system should not silently choose a substitute time. It should ask, _"that local time does not exist here; do you want to shift it?"_.

Temporal makes this an explicit decision point. You can choose one of these strategies:

  • reject: fail and ask the user,
  • earlier or later: intentionally shift,
  • compatible: fall back to compatibility behavior.

For high-impact flows (shipping windows, maintenance runs, financial cutoffs), reject is usually the safest default. If your business policy says “early is acceptable, late is not,” then earlier can be a valid fallback too, as long as that choice is explicit and documented.

The practical rule is: avoid silent coercion. If the time is impossible, either fail fast and ask the operator (reject), or intentionally bias to one side (earlier) with a clear operational tradeoff (more reliable than late, less efficient than exact timing).

Temporal.ZonedDateTime.from(
  { timeZone: "America/New_York", year: 2026, month: 3, day: 8, hour: 2, minute: 30 },
  { disambiguation: "reject" }
); // throws: local time does not exist

Also remember that timezone change events are not always one-hour. Samoa skipped a full civil day in 2011 when it moved across the date line.

If your model stores only an offset, it cannot represent those structural changes safely.

This is why “we just store UTC offset” is rarely enough for long-lived business data. Offsets tell you what happened at one moment, but they do not encode the rule system required for future or historical conversions.

Let’s see this in Bob’s console:

Bob's International Delivery

Dispatch Console

LiveOps: LON-02

Dispatch case: local time that does not exist.

Temporal Playground

Clock replay: watch spring-forward skip 02:xx and compare Date-style offset logic with Temporal.

3) Supports non-user timezones (first-class)

This is the cross-zone intent problem.

Most timezone incidents happen when systems treat timezone as an environment detail instead of business data.

A real Bob's request in this stack:

  • Customer says: "Deliver at 09:00 local New York time."
  • Bob's dispatcher enters the booking from London on a browser running Europe/London.

Now look at what happens if your backend path is Date-first and the request body is plain "2026-10-14T09:00":

  • Date assumes the operator’s local zone (Europe/London) because there is no explicit timezone in the value.
  • 09:00 becomes a different instant than 09:00 in America/New_York.
  • The same typed value now means a different real-world moment depending on who clicks the submit button.

That is how teams get “why did this delivery window move?” pages at 2 a.m.

These bugs are hard to catch in testing because different environments run with different local timezone settings.

Bob's Dispatch Console snapshot

  • Customer requested slot: 2026-10-14 09:00 in New York business time
  • Dispatcher browser with Date: 09:00 +01:00 London local => 08:00 UTC (wrong instant)
  • Correct New York instant: 09:00 -04:00 => 13:00 UTC

This is not a formatting edge case. Bob stored local intent without storing business timezone, so the same text produced the wrong instant.

With Temporal, timezone is explicit and stable:

  • capture civil intent in Temporal.ZonedDateTime,
  • store canonical timeline data with toInstant(),
  • render for any viewer by converting from that instant into the target display zone.

Why this matters: when a customer asks, “When is my package due?”, support, driver apps, notifications, and tracking pages should all show the same commitment. Temporal gives you one canonical instant plus consistent per-zone rendering.

Raw Date path

const londonDispatcherDate = new Date("2026-10-14T09:00");
console.log(londonDispatcherDate.toISOString()); // 2026-10-14T08:00:00.000Z on a London machine

The string does not include “New York”, so interpretation changes with each browser timezone.

Temporal path

  • Temporal.ZonedDateTime captures wall-clock fields and timezone as first-class values.
  • The same instant can later be rendered for London, Sydney, or any other viewer without altering intent.

Temporal keeps timezone as data:

// Bob's customer says: "09:00 on Oct 14 in New York"
const bobsDeliverySlot = Temporal.ZonedDateTime.from({
  timeZone: "America/New_York",
  year: 2026,
  month: 10,
  day: 14,
  hour: 9,
  minute: 0
});

// Store one canonical instant
const slotInstant = bobsDeliverySlot.toInstant();

// Render correctly for different viewers
const forLondonAgent = slotInstant.toZonedDateTimeISO("Europe/London");
const forSydneyTeam = slotInstant.toZonedDateTimeISO("Australia/Sydney");
const latestArrival = bobsDeliverySlot.subtract({ minutes: 45 });

This is the same pattern you want for flights and delivery windows where one zone creates the request and another zone executes it.

If a customer says “09:00 New York,” that phrase should remain New York intent all the way through your pipeline. Temporal gives you the type model to keep that explicit, instead of relying on runtime defaults.

Let’s see this in Bob’s console:

Bob's International Delivery

Dispatch Console

LiveOps: LON-02

Dispatch case: New York customer slot entered by London dispatcher.

4) Immutable by default

This is the shared-state mutation problem.

Date mutates by default.

Mutable date objects are especially risky in service code where values pass through helper functions, caches, serializers, and retry logic. One in-place mutation can silently change behavior in another code path that still holds the same reference.

That design is easy to misuse in long-lived, shared services because you can pass an object reference through multiple layers and only see the side effect later.

Temporal values are immutable by design. add and subtract return new values, while the original value stays unchanged.

That single property removes an entire class of accidental state bugs. It also makes code review easier, because time transformations become explicit value creation rather than hidden mutation.

const raw = new Date("2026-02-17T09:30");
raw.setDate(raw.getDate() + 7); // same object mutated

const plain = Temporal.PlainDateTime.from("2026-02-17T09:30");
const next = plain.add({ weeks: 1 }); // returns new value; `plain` stays unchanged

Bob's International Delivery

Dispatch Console

LiveOps: LON-02

Dispatch case: mutable helper bug in scheduling code.

5) Historical timezone changes (Samoa)

This is the historical-rule-change problem.

Timezone rules are political, and they change.

When people think about timezone bugs, they usually think about DST. In reality, civil-time policy changes include region-level rewrites, offset jumps, abolished DST, renamed zones, and date-line moves. Historical correctness requires a rule set, not just a number.

Samoa is a strong example: in 2011 it moved across the International Date Line and skipped a civil day locally.

If your model stores only offsets, you cannot represent these structural changes safely.

Named IANA zones let runtime timezone data handle those transitions correctly. Temporal leans into this by making zone-aware types first-class instead of optional add-ons.

const apiaBefore = Temporal.Instant
  .from("2011-12-29T09:00:00Z")
  .toZonedDateTimeISO("Pacific/Apia");

const apiaAfter = Temporal.Instant
  .from("2011-12-30T11:00:00Z")
  .toZonedDateTimeISO("Pacific/Apia");

console.log(apiaBefore.toString());
console.log(apiaAfter.toString());

Let’s see this in Bob’s console:

Bob's International Delivery

Dispatch Console

LiveOps: LON-02

Dispatch case: Samoa dateline shift (2011).

Temporal Playground

Clock replay: Samoa skips a civil day at the dateline shift.

6) Calendar conversions (Hebrew, Japanese, Islamic, Chinese)

This is the calendar-projection problem.

Calendar conversion is a separate concern from timezone conversion.

Teams often accidentally conflate these because both are “date formatting” in the UI layer. But they solve different problems: timezone picks local wall-clock representation; calendar picks the cultural/chronological system used to label the date fields.

Temporal keeps this explicit using withCalendar(...) on date values.

That separation is valuable for international products where a single underlying ISO date may need to be presented in multiple calendar systems without changing the underlying business date value.

const isoDate = Temporal.PlainDate.from("2026-02-17");

const hebrewDate = isoDate.withCalendar("hebrew");
const japaneseDate = isoDate.withCalendar("japanese");
const islamicDate = isoDate.withCalendar("islamic-umalqura");
const chineseDate = isoDate.withCalendar("chinese");

console.log(isoDate.toString());      // ISO 8601 view
console.log(hebrewDate.toString());   // same day in Hebrew calendar
console.log(japaneseDate.toString()); // same day in Japanese calendar
console.log(islamicDate.toString());  // same day in Islamic calendar
console.log(chineseDate.toString());  // same day in Chinese calendar

Bob's International Delivery

Dispatch Console

LiveOps: LON-02

Dispatch case: calendar conversions shown live.

7) Parsing behavior is strict and predictable

This is the parser-contract problem.

Production parsing bugs often start as “it parses on my machine”.

Loose parsing feels convenient in early development, but it creates hidden variability in production. Different engines and environments can accept, coerce, or reject the same string differently, which means parser behavior becomes an implicit dependency of your deployment surface.

Date.parse accepts many formats and varies behavior by engine and legacy compatibility needs. That is convenient for ad hoc scripts and risky for structured systems.

If you want one concrete demo of how wild Date parsing can get, use jsdate.wtf. It is a great quick reference for explaining parser inconsistencies to teams during migration planning.

Temporal is strict by design:

  • invalid or ambiguous inputs fail,
  • your code is forced to use explicit types and formats,
  • boundaries between civil and exact-time values stay visible.

That strictness is a feature, not friction. It pushes parse decisions to your boundary layer, where you can validate, normalize, and reject unsafe inputs before they contaminate scheduling or billing state.

Temporal.PlainDate.from("2026-02-17");      // ok
Temporal.PlainDate.from("02/17/2026");      // throws

Temporal.ZonedDateTime.from("2026-03-29T01:30+01:00[Europe/London]"); // explicit

Let’s see this in Bob’s console:

Bob's International Delivery

Dispatch Console

LiveOps: LON-02

Dispatch case: parser reliability on imported timestamps.

Type split is the win

This is the design win in Temporal.

Instead of one overloaded date object, Temporal makes you pick the actual type you mean:

  • Temporal.Instant — exact point in time (timeline),
  • Temporal.ZonedDateTime — civil time in a zone,
  • Temporal.PlainDate — date-only business values,
  • Temporal.Duration — explicit elapsed-time math.

That split is where most bugs get caught before they reach business logic.

In practice, this becomes a modeling discipline:

  • Instant for exact stored moments on the timeline,
  • ZonedDateTime for civil intent in a named zone,
  • PlainDate for date-only business semantics,
  • Duration for explicit elapsed-time arithmetic.

Once your team treats these as different concepts instead of one interchangeable “date thing,” a lot of recurring defects disappear because invalid operations become obvious.

// 1) Add duration to exact instant
const cutoff = Temporal.Instant.from("2026-07-01T00:00:00Z");
const grace = Temporal.Duration.from({ minutes: 90 });
const cutoffWithGrace = cutoff.add(grace); // 2026-07-01T01:30:00Z

// 2) Compare two instants and get duration
const started = Temporal.Instant.from("2026-07-01T00:05:00Z");
const lateBy = cutoff.until(started, { largestUnit: "minute" }); // PT5M

// 3) Add duration in a zone
const nyShiftStart = Temporal.ZonedDateTime.from({
  timeZone: "America/New_York",
  year: 2026,
  month: 11,
  day: 1,
  hour: 9,
  minute: 0
});
const nyShiftEnd = nyShiftStart.add({ hours: 8 });
const shiftLength = nyShiftStart.until(nyShiftEnd, { largestUnit: "hour" }); // PT8H

// 4) Same event viewed in another zone via Instant
const londonView = nyShiftStart.withTimeZone("Europe/London");
const sameEvent =
  Temporal.Instant.compare(
    nyShiftStart.toInstant(),
    londonView.toInstant()
  ) === 0; // true

// 5) PlainDate math for calendar-aware business value
const invoiceDue = Temporal.PlainDate.from("2026-10-31");
const today = Temporal.Now.plainDateISO();
const daysRemaining = today.until(invoiceDue, { largestUnit: "day" });

// 6) Explicit duration totals
const processing = Temporal.Duration.from({ hours: 1, minutes: 45 });
const processingHours = processing.total({ unit: "hour" }); // 1.75

The pattern is consistent:

  • use add/subtract to move a value,
  • use until/since to compute duration between values,
  • use toInstant() when you need exact cross-zone comparison.

Code I actually use

const TemporalApi =
  globalThis.Temporal ??
  (await import("@js-temporal/polyfill")).Temporal;

const now = TemporalApi.Now.instant();

const runAt = TemporalApi.ZonedDateTime.from({
  timeZone: "America/New_York",
  year: 2026,
  month: 3,
  day: 8,
  hour: 9
});

const nextRun = runAt.add({ days: 1 }); // same local wall-clock time next day

I keep this pattern in production because it de-risks rollout:

  • prefer native Temporal first,
  • include polyfill where required,
  • keep the app model consistent once the boundary is crossed.

The key point is consistency: pick one internal model and normalize into it early. Most migration pain comes from half-converted systems where Date and Temporal values mix unpredictably in the same flow.

Migration order I recommend

If your backend already stores exact instants (UTC timeline values), you are in a strong position.

In that model, the biggest wins from Temporal are at the edges:

  • when you receive civil time from users,
  • when you transform times between business zones,
  • and when you display times in a user or operational context.

Here is the rollout order I recommend:

  • 1) Fix ambiguous and impossible local times first. Handle fall-back repeats and spring-forward gaps as explicit decisions (earlier, later, reject) so scheduling bugs stop at input time and operators resolve intent before execution.
  • 2) Switch cross-zone conversion to named timezone flows. Move from implicit local/browser assumptions to ZonedDateTime + IANA zone IDs so business intent stays stable across teams, regions, and environments.
  • 3) Replace mutable Date helpers. Remove shared mutable date objects in core logic and adopt immutable Temporal operations so helper code can no longer create side effects in sibling call paths.
  • 4) Replace date-only operations with Temporal.PlainDate. Use PlainDate for calendar-only business values (cutoffs, due dates, billing days) so timezone offsets do not silently shift the day boundary.
  • 5) Keep Date only at integration boundaries. Use Date mainly for legacy APIs, transport compatibility, or third-party adapters, and convert into Temporal types as soon as data enters your app model.

Temporal is strict, and that strictness is what prevents a lot of “it works in testing” timezone incidents from reaching customers.

Mega playground

If you want to push this harder, use the full playground below.

You can tweak a lot of settings in one place:

  • switch between DST, Jump, Mutation, Date-only, Zone, Parsing, and History modes,
  • change source timezone, viewer timezone, disambiguation policy, offsets, and duration math,
  • run the clocks and compare Date vs Temporal behavior under different inputs.

Start in Zone Lab and then move through the other tabs.

Temporal Playground

Full Temporal playground (all controls enabled).