Five Temporal API Traps That Silently Reintroduce Date Bugs
Learn how JavaScript's Temporal API can still cause timezone, serialization, precision, and duration bugs unless five common misuse patterns are corrected.
This article is published in English.
Sources: JavaScript Temporal API: 5 Critical Mistakes and How to Fix Them!
A daily reminder notification that fires an hour off every time clocks shift for daylight saving. A support ticket logged under the wrong calendar day because the server runs in UTC while the customer sits in a completely different zone. A financial report that fails its equality check in CI intermittently, with no clear way to reproduce the failure on a local machine.
None of these are bugs caused by Date. They are Temporal bugs, and they're turning up in codebases that adopted Temporal precisely to get away from date-related bugs in the first place.
As of Node.js 26.8.2, Temporal ships globally with no experimental flag required. Chrome 144 supports it out of the box. TC39 has moved the proposal to Stage 4, meaning it's no longer a speculative future addition — it's a settled part of the language, and teams are actively using it to replace Moment.js, date-fns, and custom Date-based arithmetic. That wave of migration is precisely where the five mistakes below tend to originate.
Temporal solves the old pain points of Date — mutability, ambiguous parsing, and the lack of a real boundary between a calendar date and an exact instant in time — by enforcing a stricter, more explicit data model. The trouble starts when developers treat it as merely "a nicer Date." That assumption is what produces the mistakes described here, and these mistakes tend to be sneakier than the old ones, not easier to spot.
Mistake 1: Calling Temporal.Now.plainDateISO() Without Deciding Whose “Today” You Mean
This is probably the single most common mistake, mainly because the code that causes it looks entirely correct at a glance.
// Inside an API route running on a server in us-east-1
function getTodaysDigestDate() {
const today = Temporal.Now.plainDateISO();
return today.toString();
}
Calling Temporal.Now.plainDateISO() with no arguments resolves "now" using the system's time zone — that is, the zone the server happens to be running in, not the zone the end user is in. Picture a customer in Tokyo opening your app at 8 AM local time: if the server is in UTC and hasn't yet ticked past midnight, that customer's digest gets stamped with yesterday's date. This is the identical off-by-one problem that used to haunt new Date().toDateString() — it's simply resurfaced in code that developers assumed was safe from it purely because it uses Temporal.
A better approach:
function getTodaysDigestDate(userTimeZone) {
// userTimeZone comes from the user's profile or resolved request context,
// e.g. "Asia/Tokyo" — never the server's implicit zone.
const today = Temporal.Now.plainDateISO(userTimeZone);
return today.toString();
}
Temporal never eliminated the question of "relative to which zone?" — it just forces you to answer it explicitly instead of letting it default silently. Any call to Temporal.Now.* that omits an explicit zone is a decision you've made without realizing it. It's worth searching your codebase for unqualified calls to Temporal.Now.plainDateISO(), Temporal.Now.zonedDateTimeISO(), and Temporal.Now.plainDateTimeISO() — each one is silently answering "today, according to whom?" with "whatever zone the server happens to be running in."
Mistake 2: Assuming JSON Round-Trips a Temporal Value Back Into a Temporal Object
Temporal objects expose a toJSON() method, so passing them to JSON.stringify() appears to work seamlessly — and that apparent success is exactly what sets up the following bug.
const state = { renewalDate: Temporal.PlainDate.from("2026-11-03") };
localStorage.setItem("sub", JSON.stringify(state));
// ...later, on page reload...
const restored = JSON.parse(localStorage.getItem("sub"));
const daysLeft = restored.renewalDate.since(Temporal.Now.plainDateISO()).days;
JSON.stringify invokes toJSON() under the hood and produces a plain ISO-formatted string. But JSON.parse has no way of knowing that string used to represent a Temporal.PlainDate — it simply hands back a string, not a class instance. Running the snippet above throws TypeError: restored.renewalDate.since is not a function, and it typically does so in production, only after the value has passed through some kind of serialization boundary: localStorage, a postMessage call to a Web Worker, a prop handed to a Server Component, or a persisted Redux store. In local development, working within the same tab and the same execution tick, this issue is easy to miss entirely — it only appears once the value is actually serialized and later read back in.
A better approach:
const restored = JSON.parse(localStorage.getItem("sub"));
const renewalDate = Temporal.PlainDate.from(restored.renewalDate);
const daysLeft = renewalDate.since(Temporal.Now.plainDateISO()).days;
The underlying rule is this: every Temporal type ships with a matching .from() static constructor for reconstruction, and whenever one of these values passes through a layer that turns it into plain text or JSON first — whether that's browser storage, a worker message, an API payload, or the handoff between server and client in a framework — you are responsible for converting it back explicitly once it lands. Nothing performs that reconstruction for you automatically, and nothing raises a warning when the step gets skipped.
Mistake 3: Forgetting That Your Database Cannot Match Temporal's Precision
Temporal.Instant measures time down to the nanosecond. Practically none of the systems you hand that value to can hold onto that level of detail.
const capturedAt = Temporal.Now.instant(); // nanosecond precision
await db.query(
"INSERT INTO events (captured_at) VALUES ($1)",
[capturedAt.toString()]
);
// later, comparing the freshly captured instant to the one read back from Postgres
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // false — intermittently, not always
Postgres's timestamptz column type only retains microsecond precision, and a plain JavaScript Date only holds milliseconds. Write a nanosecond-precision Instant directly into either one, and the extra digits are quietly chopped off during the insert. The comparison in the snippet above isn't technically incorrect — it's just measuring a value against a precision level that storage never actually kept. Left unnoticed, this turns into an intermittently failing test, a deduplication check that lets an occasional "duplicate" slip through, or an audit trail where two events that occurred within the same microsecond become indistinguishable without anyone intending that outcome.
Correct usage:
const capturedAt = Temporal.Now.instant().round({ smallestUnit: "microsecond" });
await db.query(
"INSERT INTO events (captured_at) VALUES ($1)",
[capturedAt.toString()]
);
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // true, consistently
Round the value down to whatever precision your storage layer genuinely supports before writing it, rather than after you've spent time chasing a comparison that only fails intermittently.
Mistake 4: Persisting a Fixed Instant for an Event That Hasn't Happened Yet
This mistake is particularly dangerous because it typically passes testing and reaches production — the failure only shows up twice a year.
function scheduleReminder(zone) {
// "Remind me at 9 AM tomorrow" — computed once, at creation time
const fireAt = Temporal.Now.zonedDateTimeISO(zone)
.add({ days: 1 })
.toInstant();
return fireAt; // stored as-is, e.g. in a job queue's "run_at" column
}
An Instant marks a single, fixed point on the UTC timeline; once you've converted a local time into one, it no longer "remembers" that it meant 9 a.m. local time. If the scheduled date lands after a daylight saving shift relative to when the reminder was created, the UTC offset for that zone will have moved, and the notification ends up firing at 8 a.m. or 10 a.m. local time rather than 9. Nothing about the code is broken at the moment it executes — it only becomes wrong once the clocks change, which is precisely the kind of defect that gets labeled "flaky" and keeps resurfacing every spring and fall.
Correct usage:
function scheduleReminder(zone) {
// Store the wall-clock intent, not a resolved instant
const wallTime = Temporal.Now.plainDateTimeISO(zone).add({ days: 1 });
return { wallTime: wallTime.toString(), timeZone: zone };
}
function resolveNextFireTime({ wallTime, timeZone }) {
// Resolve to an actual Instant only when you're about to act on it
return Temporal.PlainDateTime.from(wallTime)
.toZonedDateTime(timeZone)
.toInstant();
}
When what you actually mean is "9 a.m. local time, regardless of what the offset ends up being," keep the wall-clock value together with the time zone, and only convert that into a concrete Instant at the very last moment — right before you act on it, not back when it was scheduled.
Mistake 5: Reaching for Millisecond Math Instead of Temporal.Duration
Habits carried over from Date.getTime() don't vanish automatically just because you've switched to a new type.
function oneWeekLater(zonedDateTime) {
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
const nextMs = zonedDateTime.epochMilliseconds + msPerWeek;
return Temporal.Instant.fromEpochMilliseconds(nextMs)
.toZonedDateTimeISO(zonedDateTime.timeZoneId);
}
A calendar week doesn't always add up to exactly 168 hours. When the span you're calculating crosses a daylight saving boundary, adding 7 * 24 hours' worth of milliseconds to an epoch value produces the wrong local clock time — an appointment meant to land "one week later" drifts by an hour, even though the calendar date itself is untouched. This is essentially a quieter variant of Mistake 4, and it tends to creep back into any function still built around raw millisecond arithmetic, simply because that was how Date math used to be done.
Correct usage:
function oneWeekLater(zonedDateTime) {
return zonedDateTime.add({ weeks: 1 });
}
Methods like .add() and .subtract(), built around Temporal.Duration, are calendar- and time-zone-aware, so "one week later" correctly resolves to the same wall-clock time on the same weekday, daylight saving included. The moment you find yourself computing a raw millisecond offset and converting it back afterward, you've stepped outside the part of the Temporal API designed to handle this correctly.
What Connects All Five Mistakes
Every one of these problems traces back to the same root cause: Temporal forces you to be explicit about time zone, precision, and intent, and each mistake comes from quietly falling back on the implicit assumptions Date used to make instead. Temporal was never meant to be a mechanical swap-in replacement — it's a stricter model wearing method names that merely look familiar.
Quick recap:
- Never invoke
Temporal.Now.*without explicitly deciding which time zone's "now" you actually need - Restore Temporal values with
.from()on both sides of any serialization boundary — JSON turns them into plain strings, not live objects, during the round trip - Round precision down to match what your storage layer can actually retain before saving, not later while debugging an equality check that fails unpredictably
- For future-dated events, persist the wall-clock time alongside its time zone, and only resolve it into an
Instantright when you're about to act on it - Favor
Temporal.Duration-based arithmetic —.add(),.subtract()— over epoch-millisecond calculations for anything involving calendar dates
Temporal does genuinely resolve well over a decade of accumulated problems with Date. But it trades that old mess for a smaller number of much sharper edges, and now that both Node 26 and Chrome 144 include it by default, more teams are going to run into those edges directly in production as they migrate over.