Skip to content
Chapter 9Lesson 3

Date's problems and the Temporal pivot

Why JavaScript's legacy Date is structurally broken and how the new Temporal API replaces it as the way you handle time across the course.

A reminder-email job is scheduled to fire “every Monday at 9 AM in the user’s timezone.” The implementation is the obvious one: read the last fire time, add seven days’ worth of milliseconds, and write the new time back.

const nextFire = new Date(lastFire.getTime() + 7 * 24 * 60 * 60 * 1000);

Twice a year, the email fires an hour early or an hour late. The on-call engineer can reproduce it locally only by setting the system clock to the day before a daylight saving transition. The fix the engineer reaches for, special-casing the DST week and adjusting by an hour, is not the real fix. The bug is structural. Date is a count of UTC milliseconds with no concept of “9 AM in Sydney,” so any code that adds twenty-four hours to a millisecond count will eventually produce a DST bug. The cure isn’t smarter arithmetic; it’s a different type.

The platform finally has that different type. Temporal advanced to TC39 Stage 4 on March 11, 2026 and shipped unflagged in Node 26 on May 5, 2026, the course’s deploy target on Vercel. This lesson explains why Date produces this kind of bug structurally, introduces the five Temporal types that replace it, and installs the three rules the rest of the course relies on. The full Temporal architecture, covering the arithmetic surface, DST scheduling, formatting, and storage codecs, lands in unit 17 on time and internationalization. Here, you learn which type to reach for.

The DST-drift bug isn’t a one-off. Date ships with eight design flaws baked in, grouped into three clusters: traps in the API surface, traps in how it parses and reports values, and traps in its precision. They share one root. Date overloads three jobs onto one shape: a UTC instant, a calendar date, and a wall clock, all represented as a millisecond count plus a few formatters that read the host’s local timezone. When you call a Date method, the type doesn’t tell you which of those three roles is in play, so the runtime guesses, and sometimes it guesses wrong.

Read the catalogue once. You don’t need to memorize all eight, because the exercise after the third cluster will help them stick.

In these three behaviours, the method signatures themselves are the mistake.

new Date(2026, 0, 31);
// → January 31, 2026 — month is zero-indexed, day is one-indexed
new Date(2026, 12, 1);
// → January 1, 2027 — months overflow silently
const d = new Date('2026-05-15');
d.setMonth(5);
// mutates d in place; no return value
const week = 7 * 24 * 60 * 60 * 1000;
const nextWeek = new Date(d.getTime() + week);
// 'a week' as a number; breaks across DST

Zero-indexed months next to one-indexed days is the first paper cut. Mutation via setX is the second: pass a Date to a function and the function can silently alter the caller’s value. The third is the lack of a duration type. Any span you need, such as 30 days or 2 hours, becomes a raw number of milliseconds, and that number has no way to know that crossing a DST boundary turns “+24 hours” into either 23 or 25 wall-clock hours.

In these three behaviours, the same call means different things in different contexts.

new Date('2026-05-15').toISOString();
// → 2026-05-15T00:00:00.000Z (UTC, by spec)
new Date('2026-05-15 00:00').toISOString();
// → depends on the runtime's local timezone
new Date('garbage');
// → Invalid Date — typeof is 'object', instanceof Date is true
new Date('garbage').getTime();
// → NaN
new Date().toString();
// → host-locale-and-tz-dependent; differs dev vs prod

The first pair is the worst of them. A single space between the date and the time changes the parse from UTC to local: the string looks almost the same, but the timezone meaning is different. Production code that accepts a date string from a third party passes its tests on a UTC server and then breaks in a non-UTC environment.

The second is the Invalid Date sentinel . new Date('garbage') doesn’t throw; it returns a Date instance whose internal time is NaN. The type system says it’s a Date, but the runtime says it isn’t. To avoid silently propagating bad data, every downstream call has to check isNaN(d.getTime()) first.

The third is toString, whose output depends on the host’s locale and timezone. Logging dates with toString produces strings that look one way on a developer’s laptop, another way on a Vercel build runner, and a third way in Sentry breadcrumbs. A value on the wire should be canonical, and toString isn’t.

The space-versus-T trap is worth predicting once. Imagine you’re running the two lines below on a server whose timezone is set to UTC. What do they print?

Predict what this program prints, then press Check.

console.log(new Date('2026-05-15').toISOString());
console.log(new Date('2026-05-15 00:00').toISOString());

In these two behaviours, the resolution of the underlying number matters.

new Date(99, 0, 1);
// → January 1, 1999 — legacy two-digit-year heuristic
Date.now();
// → milliseconds — Postgres timestamptz is microseconds
performance.now();
// → sub-millisecond resolution — Date's millisecond floor is coarser than either

The Year-100 trap is the smallest flaw on the list: new Date(99, 0, 1) is 1999, not the year 99. A legacy two-digit-year heuristic remains in the runtime for backward compatibility, and it reads any small year as if it were written with two digits.

The precision trap matters more. Postgres timestamptz stores microseconds and performance.now() returns sub-millisecond resolution. Date measures only down to the millisecond, which is coarser than either, so a high-frequency event log round-tripped through Date loses the ordering of any events that land in the same millisecond.

The cure for all eight is the same: more types, not more methods. Date is structurally unsafe because it overloads “an instant in real time,” “a calendar date,” and “a wall clock” onto one shape. The replacement gives each of those three meanings its own type, and each type exposes an API that refuses the operations that don’t make sense for it.

Before you move on, sort the behaviours below. Some are Date misfeatures, and some are normal language behaviour that you might mistake for one.

Sort each behaviour into the right bucket. Drag each item into the bucket it belongs to, then press Check.

Date misfeature
Fine, not a misfeature
Zero-indexed months
new Date('garbage') returns an Invalid Date instead of throwing
d.setMonth(5) mutates the caller’s value
Space vs T in an ISO string flips UTC to local
new Date(99, 0, 1) is the year 1999
Adding 24 * 60 * 60 * 1000 breaks across DST
JSON.stringify(new Date()) produces an ISO 8601 string (via toJSON)
Date.now() returns milliseconds since the Unix epoch
Two Date instances built from the same millisecond are not ===

The right column holds reference identity and the Unix epoch. Neither is a bug; they are simply how JavaScript objects and the underlying clock work. The left column is what the replacement types eliminate.

The eight flaws are abstract on their own, so three real bugs bring them down to earth. Each one is a structural consequence of Date’s overload rather than a bad-luck edge case, and each one points at a specific Temporal type as the cure.

The DST reminder drift. The Monday-morning reminder email fires at 8 AM or 10 AM for half the year. The math (+ 7 * 86_400_000) advances seven UTC days, but the user’s wall clock isn’t measured in UTC days; it shifts an hour when their region enters or leaves DST. The cure is Temporal.ZonedDateTime paired with add({ days: 1 }). The type knows the user’s IANA tz, and the add operation treats “a day” on a wall clock as a calendar concept rather than a fixed number of milliseconds.

The date-line invoice. An invoice is created with dueDate: '2026-05-15'. The server stores it as 2026-05-15T00:00:00Z, UTC midnight on May 15. The UI reads it back as a Date and renders it in the user’s local timezone. For a user in Los Angeles (UTC-8), UTC midnight on May 15 falls at 4 PM on May 14, so that is the date the UI prints. The invoice the customer agreed was due on the 15th now displays as due on the 14th. The cure is Temporal.PlainDate. A calendar date has no time and no timezone, so it is the same May 15 in Sydney, Tokyo, and Madrid, which is exactly what a calendar date means.

The audit-log timezone smear. A Server Component renders an audit-log row’s timestamp with new Date(row.createdAt).toLocaleString(). The server clock is UTC, so users in São Paulo, Berlin, and Honolulu all see the same UTC string instead of their own local time. The cure is to store Temporal.Instant in Postgres, send the ISO string over the wire, and format it at the boundary, either on the client or server-side using the user’s profile timezone. The full pattern lands in unit 17. The choice this lesson installs is the type itself: Temporal.Instant for “when did this happen.”

Those three bugs name three of the Temporal types. The full catalogue comes next.

The Temporal proposal replaces one type with five. Each type names one of the meanings Date overloaded onto its single shape, and each one refuses the operations that don’t make sense for it. The full surface is large, covering arithmetic, comparisons, rounding, and calendar systems, and unit 17 walks through it in depth. What you need here is the map of which type carries which meaning.

Type
What it names
Use it for
Postgres column
Temporal.Instant
A UTC moment in real time, nanosecond precision, no timezone.
Server timestamps: createdAt, lastSeenAt, webhook arrival.
timestamptz
Temporal.ZonedDateTime
An Instant paired with an IANA timezone, DST-aware.
Wall-clock display, recurring schedules: "every Monday 9 AM in the user's tz".
Derived, not stored
Temporal.PlainDate
A calendar date — year, month, day — with no time and no tz.
dueDate, birthDate, anniversaries; "May 15" semantics.
date
Temporal.PlainDateTime
A wall-clock date and time with no timezone commitment.
Rare. "5 PM local" without specifying which local. Most domain code does not need it.
Rarely stored
Temporal.Duration
An explicit span: years, months, weeks, days, hours, minutes, seconds, and sub-second units down to nanoseconds.
"30 days", "1 month", "2 hours" — never as raw milliseconds.
Not stored; computed

The Postgres column on the right is a forward pointer to unit 17. For now, focus on the leftmost three columns.

Three properties of the catalogue carry the rest of the architecture.

The type tells you what you have. This is the key shift. With Date, you can call due.getHours() on what you intended as a calendar date, and the API doesn’t refuse. With Temporal, PlainDate has no getHours() method, because the operation is meaningless on a calendar date, so it simply isn’t there. The type system and the runtime agree on what each value is for. The ambiguity is refused at the API instead of being caught downstream.

Immutability everywhere. Every Temporal operation returns a new instance. instant.add({ minutes: 5 }) does not change instant; it returns a new Instant. No method ever changes the receiver. This pairs with the const discipline you already use for value bindings: the value a name points at never changes underneath you.

Errors over sentinels. Temporal.Instant.from('garbage') throws a RangeError. There is no Invalid Instant sentinel, so bad input cannot propagate. The catch belongs at the parse seam, meaning the route handler, the Server Action wrapper, or the third-party adapter, the same discipline you installed for JSON.parse in the first lesson of this chapter. Data on the wire is unknown until you validate it, and Temporal from() follows that same pattern.

Temporal is native code in Node 26, and a polyfill everywhere else. The seam between those two is the one piece of plumbing this lesson installs.

Stage 4 and Node 26. Temporal advanced to TC39 Stage 4 on March 11, 2026, so the proposal is now part of the ECMAScript 2026 specification. Node 26.0.0 shipped on May 5, 2026 with Temporal unflagged in the default global. That is the course’s deploy target on Vercel’s Node 26 runtime. Node 26 promotes to LTS in October 2026.

Node 24 LTS, the gap. Until Node 26 promotes to LTS, production codebases still on Node 24 don’t have native Temporal. Two polyfills are mature in 2026: temporal-polyfill from FullCalendar, roughly 20 KB gzipped, with near-complete spec compliance; and @js-temporal/polyfill from the TC39 champions themselves, in the 45 to 60 KB range depending on version. The course picks temporal-polyfill for the smaller bundle.

Browser support, not a blocker. Firefox 139 enabled Temporal by default in mid-2025. Chrome 144 ships it in 2026, and Safari is still pending. None of this is on the critical path for the course, because the SaaS the course builds renders dates server-side and ships ISO 8601 strings to the browser, so the browser never needs a Temporal runtime to display a date. Locale-aware formatting also lands in unit 17.

Runtime
Native Temporal
Course's reach
Node 26+ Course deploy target on Vercel
Unflagged since May 5, 2026
Direct import — no polyfill.
Node 24 LTS Until Node 26 promotes (Oct 2026)
Pre-Temporal runtime
temporal-polyfill re-exported from lib/temporal.ts
Browser Firefox 139+, Chrome 144 (late 2026), Safari pending
Patchy across vendors
Server-rendered ISO strings — no client-side dependency.
Runtime support for Temporal, May 2026.

Here is the rule the whole course depends on: exactly one file in the codebase imports the polyfill, and every other file imports from that file. The convention has a name, lib/temporal.ts, and a shape:

lib/temporal.ts
import { Temporal } from 'temporal-polyfill';
export { Temporal };

That is the whole file for a project pinned to Node 24: two lines. The point isn’t the brevity; it’s the seam . Once Node 26 becomes the project’s pinned runtime, those two lines collapse to one re-export from the native global. Every other file in the codebase already imports Temporal from @/lib/temporal, so the swap is a single-file change.

lib/temporal.ts
import { Temporal } from 'temporal-polyfill';
export { Temporal };
export const instantFromDate = (d: Date): Temporal.Instant =>
Temporal.Instant.fromEpochMilliseconds(d.getTime());
export const dateFromInstant = (i: Temporal.Instant): Date =>
new Date(i.epochMilliseconds);

The single import path is the discipline. Every application file reaches for Temporal from @/lib/temporal, never from temporal-polyfill directly and never from globalThis.Temporal. The reason isn’t aesthetic: mixing the native and polyfill Temporal in the same process breaks instanceof checks and breaks cross-instance interop with from(). The two values look identical from the outside, but they aren’t the same class.

lib/temporal.ts
import { Temporal } from 'temporal-polyfill';
export { Temporal };
export const instantFromDate = (d: Date): Temporal.Instant =>
Temporal.Instant.fromEpochMilliseconds(d.getTime());
export const dateFromInstant = (i: Temporal.Instant): Date =>
new Date(i.epochMilliseconds);

The first codec takes a Date handed back by a third-party SDK and produces a Temporal.Instant that the rest of the application can read. The arrow form, the explicit return type, and the single positional parameter all follow the project’s /lib helper conventions.

lib/temporal.ts
import { Temporal } from 'temporal-polyfill';
export { Temporal };
export const instantFromDate = (d: Date): Temporal.Instant =>
Temporal.Instant.fromEpochMilliseconds(d.getTime());
export const dateFromInstant = (i: Temporal.Instant): Date =>
new Date(i.epochMilliseconds);

This is the reverse codec, for the rare path where you hand a Date back to an SDK that requires one. Application code never reaches for either of these directly; the call sites live inside SDK adapters, and lib/billing/ in unit 12 is the canonical example.

1 / 1

Some projects run Node 24 in one environment and Node 26 in another, for example local dev on the new runtime and CI on the LTS. For those, the same file picks up a one-line runtime guard so it works on either.

// lib/temporal.ts (runtime-guarded variant)
import { Temporal as TemporalPolyfill } from 'temporal-polyfill';
export const Temporal = globalThis.Temporal ?? TemporalPolyfill;

For a project pinned to one Node version, the simpler re-export is the right choice. The course’s pinned runtime is one version at a time, so the first form is the default.

The pivot happens at the type level, but Date doesn’t vanish from the codebase. Two cases keep it in scope, and naming them up front prevents the over-correction where students try to purge Date from places it legitimately belongs.

SDK ingress. Third-party SDKs hand back Date instances on their callbacks and response objects. Stripe’s SDK is the canonical example: its wire format is Unix seconds (10-digit integers), but the JavaScript SDK wraps them in Date instances on the way out, so your application code receives subscription.created as a Date object. The application’s domain doesn’t read that Date directly; it converts at the seam.

Stopwatch measurements. Date.now() and performance.now() are the right tools for “how many milliseconds did this operation take,” measurements where absolute time has no meaning and only the difference between two reads counts. The result is a number, not a value that travels into the domain. performance.now() is the better choice when it’s available, since it is sub-millisecond and monotonic, so it doesn’t jump when the system clock corrects itself.

Everything else is Temporal. If a value gets stored in Postgres, displayed in the UI, compared against other moments in domain logic, or scheduled through a background job, it is a Temporal type. If instead it is a measurement seam, a stopwatch or a clock differential, then Date.now() or performance.now() is fine.

The conversion is small enough to read in one pass.

lib/temporal.ts
import { Temporal } from 'temporal-polyfill';
export { Temporal };
export const instantFromDate = (d: Date): Temporal.Instant =>
Temporal.Instant.fromEpochMilliseconds(d.getTime());
export const instantFromUnixSeconds = (s: number): Temporal.Instant =>
Temporal.Instant.fromEpochMilliseconds(s * 1000);

The only place Date touches Temporal. Most projects only need the first function. The second is for SDKs that hand you raw Unix seconds instead of a Date wrapper, and Stripe is the canonical example.

At this stage the conversion goes one direction: a third-party Date becomes a domain Temporal.Instant. The reverse, dateFromInstant for SDKs that take a Date as input, lands in unit 17 alongside the Drizzle codec. The principle is the same either way: one converter, one place.

The whole lesson comes down to three rules. Memorize them, because the rest of the course assumes them.

  1. Never new Date(year, month, day) or date.setX(...) in application code. Construction goes through Temporal.PlainDate.from('2026-05-15') or Temporal.Now.instant(). Mutation never comes up, because every Temporal operation returns a new instance.

  2. Convert SDK Date to Temporal.Instant at the seam. Use instantFromDate from lib/temporal.ts. No raw Date propagates inward from a third-party SDK.

  3. Import Temporal from @/lib/temporal. Never from temporal-polyfill directly, never from globalThis.Temporal. One seam, one import path, one place to swap when Node 26 becomes the pinned runtime.

These three rules are the pivot the rest of the time architecture turns on. Unit 17 builds on top of them: Drizzle codecs for timestamptz and date columns, the users.timeZone profile column, DST-aware schedules in Trigger.dev, and locale-aware formatting via Intl.DateTimeFormat. Every lesson in that unit refers back here. One more habit is worth filing away even though you don’t need it yet: 2026 SaaS projects don’t install date-fns, dayjs, luxon, or moment, because the platform is the library now.

Two short drills reinforce the type catalogue and the rule list.

Match each scenario to the Temporal type you would reach for.

Pick the right Temporal type for each scenario. Click an item on the left, then its match on the right. Press Check when done.

An invoice’s due date
Temporal.PlainDate
The moment a Stripe webhook arrived at the route handler
Temporal.Instant
Schedule “every Monday at 9 AM in the user’s timezone”
Temporal.ZonedDateTime
The SLA window — 30 days, used in arithmetic
Temporal.Duration
”5 PM local” — no timezone commitment
Temporal.PlainDateTime

Then a single multiple-choice question on the rule list.

Three of these lines are fine. One is a bug. Which?

Temporal.Now.instant() to stamp createdAt on a new database row.
instantFromDate(stripeSubscription.current_period_end) at the adapter seam.
new Date(2026, 5, 15) to compute the user’s signup anniversary.
import { Temporal } from '@/lib/temporal' at the top of a Server Component.

These point ahead to the surface you’ll meet in unit 17.