When Temporal API steps in to replace date-fns and dayjs, the structural reason why timezone bugs disappear
Having built up my approach with the advisor, I'll now produce the full translation.
Having written Node.js backends for five years, date/time issues have always been an area that leaves a bad aftertaste. Schedulers drifting an hour after a DST transition, or new Date("2024-03-10") being parsed as UTC and pushing dates off by a day. Teams inevitably ended up layering a timezone plugin onto date-fns or dayjs and then wrapping everything in custom utility functions on top of that.
In March 2026, TC39 finalized the Temporal API at Stage 4, officially incorporating it into the ECMAScript 2026 specification. Then in May of the same year, Node.js 26 shipped with V8 14.6, opening up a backend environment where Temporal works out of the box without flags or polyfills. The question has shifted from "when can we use this?" to "how do we migrate?"
This post covers the process of removing date-fns and dayjs from a Node.js · TypeScript backend codebase and migrating to the Temporal API. Rather than a simple API introduction, we'll look at why timezone bugs occur, how Temporal's type system structurally prevents them, and walk through an actual migration sequence.
Core Concepts
Three Structural Flaws in the Date Object
Date is designed in a way that makes it hard to use correctly. Here are three flaws.
First, it is a mutable object. Methods like setHours() and setDate() modify the original directly. When references are passed around in async code, mutations happening somewhere in the chain are hard to trace.
const deadline = new Date("2026-07-25");
function addWeek(d) {
d.setDate(d.getDate() + 7); // mutates the original
return d;
}
addWeek(deadline);
console.log(deadline); // already 2026-08-01 at this pointSecond, there is a long history of inconsistency between parsing rules and actual implementations. ECMA-262 §21.4.3.2 specifies that date-only ISO strings like "2024-03-10" are interpreted as UTC, and current major engines comply. However, past implementations existed that treated them as local time, and Date.parse still has a wide implementation-defined range for other input formats. The cognitive overhead of always having to verify "is this UTC or local?" persists.
Third, arithmetic is wrong at DST boundaries. Adding +24h on the day DST ends actually gives you 23 or 25 hours. Date makes no distinction between whether +1day should mean "the same time the next calendar day by wall clock" or "exactly 86,400 seconds later."
Temporal's Type System — Choosing the Right Type for the Job
Instead of one monolithic date-time class, Temporal is a set of immutable types separated by purpose. It can feel unclear which type to pick at first, but the decision tree below covers it.
flowchart TD
START[Working with a date/time value]
Q1{Absolute time?<br/>Or wall-clock time?}
Q2{Do you need a timezone?}
Q3{Date only?<br/>Or date and time?}
START --> Q1
Q1 -->|Absolute time<br/>DB storage · timestamp| INSTANT[Instant<br/>Nanosecond precision in UTC]
Q1 -->|Wall-clock time| Q2
Q2 -->|Yes| ZDT[ZonedDateTime<br/>DST-aware arithmetic with timezone]
Q2 -->|No| Q3
Q3 -->|Date only| PLAIN_DATE[PlainDate<br/>Birthdays · expiry dates · holidays]
Q3 -->|Date and time| PLAIN_DT[PlainDateTime<br/>e.g. "March 10 at 2pm"]| Type | Use case | Timezone included |
|---|---|---|
Temporal.Instant |
Absolute UTC time, timestamp replacement | No (UTC-based value) |
Temporal.PlainDate |
Date only (birthdays, expiry dates, holidays) | No |
Temporal.PlainDateTime |
Date + time, no timezone | No |
Temporal.ZonedDateTime |
Date + time + timezone, DST-aware arithmetic | Yes |
Temporal.Duration |
Time interval | N/A |
In the final Stage 4 spec, timezones and calendars are handled as string identifiers (e.g. "Asia/Seoul", "iso8601") rather than separate classes. The Temporal.TimeZone and Temporal.Calendar classes that existed in draft stages have been removed, so be careful when reading older material.
The separation between Plain* types and ZonedDateTime is the key point. It prevents the mistake of treating a timezone-free value as an absolute time at the type level. Combined with TypeScript, this becomes a compile-time check.
Immutability — Why You Can Share Safely Across Async Code
Every Temporal object is immutable. Operations like .add(), .subtract(), and .with() always return a new instance.
const start = Temporal.PlainDate.from("2026-07-25");
const end = start.add({ days: 30 }); // start remains unchanged
console.log(start.toString()); // "2026-07-25"
console.log(end.toString()); // "2026-08-24"When sharing date objects across Promise chains or async/await boundaries, you no longer need to worry about .setDate() or similar methods mutating the original somewhere in the chain.
Making DST Ambiguity Explicit in Code
DST transitions have two special moments.
- Gap: A time that ceases to exist when clocks spring forward. For example, 2:30 AM on the second Sunday in March for the US Eastern timezone is skipped.
- Fold: A time that appears twice when clocks fall back. 1:30 AM on the last Sunday in October for Europe/London exists twice.
The old Date handled this ambiguity implicitly, and you couldn't tell from the code alone which 1:30 was meant. Temporal makes this decision explicit in the code via the disambiguation option.
const dt = Temporal.PlainDateTime.from("2026-10-25T01:30");
// Fold situation where 1:30 appears twice when DST ends (Europe/London)
const earlier = dt.toZonedDateTime("Europe/London", { disambiguation: "earlier" });
const later = dt.toZonedDateTime("Europe/London", { disambiguation: "later" });
// Setting 'reject' throws an exception at an ambiguous time
const strict = dt.toZonedDateTime("Europe/London", { disambiguation: "reject" });
disambiguationOption Summary
'earlier': Selects the earlier time during a fold'later': Selects the later time during a fold'compatible': Shifts forward in a gap, appliesearlierin a fold (default)'reject': Throws an exception for both gaps and folds
The question "what was the intent behind this DST handling?" disappears from code reviews.
Practical Application
1. Handling Timestamps in a Server API — DB ↔ Temporal Boundary
This is the most common pattern: store in UTC in the DB and convert to the user's timezone when responding.
// Parse a UTC timestamp received from the DB
const instant = Temporal.Instant.from("2026-07-25T09:00:00Z");
// Convert to Seoul time
const seoul = instant.toZonedDateTimeISO("Asia/Seoul");
console.log(seoul.toString());
// "2026-07-25T18:00:00+09:00[Asia/Seoul]"
// Calculate next midnight — DST applied automatically
const nextMidnight = seoul.startOfDay().add({ days: 1 });ORMs like Prisma and TypeORM still expect Date objects. It is recommended to place a conversion wrapper at the boundary.
// ORM ↔ Temporal boundary conversion utility
function toInstant(date: Date): Temporal.Instant {
return Temporal.Instant.fromEpochMilliseconds(date.getTime());
}
function toDate(instant: Temporal.Instant): Date {
return new Date(instant.epochMilliseconds);
}2. Recurring Scheduling Across DST
The key insight in recurring scheduling is that "every Monday at 9 AM" can differ from +7 * 24h. When calculated with Date during a DST-transition week, 9 AM becomes 8 AM or 10 AM. Since New York's spring-forward point is 2 AM on March 8, 2026, you must start from March 1 — before the DST transition — for the boundary-crossing effect to be visible.
// March 1 at 9am in New York, within the EST range (offset -05:00)
const meeting = Temporal.ZonedDateTime.from({
timeZone: "America/New_York",
year: 2026, month: 3, day: 1,
hour: 9, minute: 0,
});
console.log(meeting.toString());
// "2026-03-01T09:00:00-05:00[America/New_York]"
// +7 days later, March 8 is already in the EDT (-04:00) range
// ZonedDateTime does arithmetic by wall-clock time, so 9am is preserved
const nextWeek = meeting.add({ days: 7 });
console.log(nextWeek.toString());
// "2026-03-08T09:00:00-04:00[America/New_York]"Processing the same scenario with Date.setDate(d.getDate() + 7) adds exactly 168 UTC hours, so the wall-clock time on March 8 ends up at 10 AM instead of 9 AM.
3. Date Calculations in Business Logic — Eliminating Timezones with PlainDate
Subscription expiry dates, coupon validity periods, and business-day calculations do not need a timezone. Using PlainDate eliminates timezone concerns entirely.
// 1-month expiry from subscription start date
const start = Temporal.PlainDate.from("2026-07-25");
const expires = start.add({ months: 1 });
console.log(expires.toString()); // "2026-08-25"
// Days remaining from today until expiry
const today = Temporal.Now.plainDateISO();
const daysLeft = today.until(expires).days;
console.log(`${daysLeft} days until expiry`);4. Multi-Timezone Meeting Coordination
This pattern expresses the same Instant as ZonedDateTime values in multiple timezones. The absolute time is one; only its representation differs.
const seoulSlot = Temporal.ZonedDateTime.from("2026-07-25T10:00:00[Asia/Seoul]");
const nySlot = seoulSlot.withTimeZone("America/New_York");
const londonSlot = seoulSlot.withTimeZone("Europe/London");
// Same absolute time expressed in each local time
console.log(seoulSlot.toString()); // "2026-07-25T10:00:00+09:00[Asia/Seoul]"
console.log(nySlot.toString()); // "2026-07-24T21:00:00-04:00[America/New_York]"
console.log(londonSlot.toString()); // "2026-07-25T02:00:00+01:00[Europe/London]"5. Patterns for Migrating date-fns · dayjs Code to Temporal
// date-fns (Before)
import { addDays, format } from "date-fns";
const next = addDays(new Date("2026-07-25"), 7);
const str = format(next, "yyyy-MM-dd");
// Temporal (After)
const next = Temporal.PlainDate.from("2026-07-25").add({ days: 7 });
const str = next.toString(); // "2026-08-01"When formatting is needed, Temporal has no convenience API like format("yyyy-MM-dd HH:mm"). Use Intl.DateTimeFormat, though calling toLocaleString() directly on a ZonedDateTime tends to be more concise.
const zdt = Temporal.ZonedDateTime.from("2026-07-25T18:30:00[Asia/Seoul]");
// Recommended — pass locale options directly to ZonedDateTime
zdt.toLocaleString("ko-KR", {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
// "2026. 07. 25. 오후 06:30"
// Use Intl.DateTimeFormat if a reusable formatter is needed
const formatter = new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
formatter.format(zdt); // Temporal types are supported directly as argumentsPros and Cons
Before and After Comparison
| Item | Details | |
|---|---|---|
| ✅ | Dependency elimination | Removes date-fns · dayjs, their timezone/utc plugins, and in-house wrapper utility layers (eliminates the recurring burden of managing growing date-fns imports even after tree-shaking) |
| ✅ | Structural timezone safety | Plain* vs ZonedDateTime type separation prevents mistakes at the type level |
| ✅ | Immutability | Structurally eliminates side-effect bugs from setHours/setDate-style mutations |
| ✅ | Explicit DST control | The disambiguation option leaves intent in the code |
| ✅ | Automatic IANA DB integration | No need to manage tzdata bundles directly; handled by engine updates |
| ✅ | Nanosecond precision | Instant supports nanosecond resolution (Date is milliseconds) |
| ✅ | Ready to use in Node.js 26 | Native support without a polyfill |
| ⚠️ | Not supported in stable Safari | As of July 2026, a polyfill is required for frontend use |
| ⚠️ | Serialization format interoperability | External systems may fail to parse ZonedDateTime's RFC 9557 bracket notation |
| ⚠️ | No built-in formatting | Requires toLocaleString or Intl.DateTimeFormat |
| ⚠️ | ORM compatibility | TypeORM · Prisma still expect Date → boundary conversion layer required |
| ⚠️ | Learning curve | Initial confusion when choosing the right type for each situation |
date-fns supports tree-shaking, so importing only addDays adds just a few KB to your bundle. The real gain from adopting Temporal is not the KB savings but the ability to remove the dependency graph, plugins, and in-house wrapper utility layer altogether.
Common Mistakes in Practice
1. Serializing ZonedDateTime directly to JSON
All Temporal types implement toJSON(), so passing them into JSON.stringify() will serialize them as strings. The question is what format they serialize to.
const instant = Temporal.Instant.from("2026-07-25T09:00:00Z");
JSON.stringify({ createdAt: instant });
// '{"createdAt":"2026-07-25T09:00:00Z"}' — ISO 8601, standard
const zdt = instant.toZonedDateTimeISO("Asia/Seoul");
JSON.stringify({ createdAt: zdt });
// '{"createdAt":"2026-07-25T18:00:00+09:00[Asia/Seoul]"}' — RFC 9557 bracket notationThe [Asia/Seoul] bracket format returned by ZonedDateTime.toJSON() is an extended notation defined in RFC 9557. Most JSON parsers, ORMs, external APIs, and mobile clients do not yet understand this format, which easily leads to parse failures or misinterpretation.
// Normalize to Instant at the boundary with external systems
JSON.stringify({ createdAt: zdt.toInstant().toString() });
// '{"createdAt":"2026-07-25T09:00:00Z"}'For REST API response layers, it is safer to establish a convention of always converting to Instant (or a pure ISO 8601 string) at external boundaries.
2. Forgetting the Date conversion at the ORM boundary
// Wrong — Prisma does not understand Temporal.Instant
await prisma.event.create({
data: { scheduledAt: Temporal.Instant.from("2026-07-25T09:00:00Z") }
});
// Correct — convert to Date
await prisma.event.create({
data: {
scheduledAt: new Date(
Temporal.Instant.from("2026-07-25T09:00:00Z").epochMilliseconds
)
}
});3. Treating PlainDateTime as a timezone-aware value
PlainDateTime is a wall-clock representation with no timezone information. To store it as an absolute time in a DB, you must also resolve which region's wall clock it refers to. Otherwise "2026-07-25 14:00" could be Seoul or New York, and the interpretation at write time and read time will diverge.
// Wrong intent — assumed "saving 2pm Seoul time",
// but absolute time is unresolved without a timezone
const dt = Temporal.PlainDateTime.from("2026-07-25T14:00:00");
// Correct — specify timezone to resolve wall-clock → absolute time
const zdt = Temporal.ZonedDateTime.from({
timeZone: "Asia/Seoul",
year: 2026, month: 7, day: 25, hour: 14, minute: 0,
});
const instant = zdt.toInstant(); // store this value as UTC in the DBClosing Thoughts
The Temporal API was designed not around "be more careful" but around "make it hard to use incorrectly." It separates timezone-free values from timezone-aware values at the type level, blocks side-effects through immutable objects, and forces DST-handling intent to be recorded in the code.
If you are on Node.js 26 (entering LTS in October 2026), you can start right now. There is no need to change the entire codebase at once. Following the sequence below lets you expand incrementally while keeping risk low.
- Define conversion utility functions (
toInstant/toDate) for the ORM boundary in one place. Without this adapter,epochMillisecondsround-trip code will scatter across every subsequent step. - Write all newly created date/time code with
Temporal. Existing code does not need to be touched. - Replacing scheduling and recurring-event logic where DST bugs have occurred or are likely with
ZonedDateTimegives you the greatest immediate impact. - Establishing a convention to normalize to
Instantat the response serialization stage prevents external-system compatibility problems caused by bracket notation.
For environments running Node.js 24 or below, temporal-polyfill (under 20 KB, spec-compliant, TypeScript types included) lets you start the same way.
References
- Temporal - MDN Web Docs
- TC39 Proposal Temporal (GitHub)
- TC39 Temporal Official Docs
- TC39 Stage 4 Advances Temporal - Socket.dev
- Temporal Is Now Official - Bloomberg
- Temporal: The 9-Year Journey - Bloomberg JS Blog
- Node.js 26 Released: Temporal API Enabled by Default - InfoQ
- The History of Date in JavaScript - NodeSource
- Temporal API Is Finally in ECMAScript 2026: Replace date-fns and dayjs Today - jsmanifest
- Exploring Temporal API - Better Stack
- JavaScript Temporal in 2026 - Bryntum
- date-fns v4 vs Temporal API vs Day.js 2026 - PkgPulse
- temporal-polyfill (npm)
- Moving From Moment.js To The JS Temporal API - Smashing Magazine
- JavaScript Timezones: Dates, Temporal API & Libraries 2026 - Crosscheck
- RFC 9557 - Date and Time on the Internet: IXDTF Extensions