Skip to content
Chapter 9Lesson 3

Date's problems and the Temporal pivot

Why JavaScript's Date is structurally broken, and how the Temporal API replaces it across the course.

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

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

Twice a year the email fires an hour early or late, reproducible only by setting the system clock to the day before a daylight saving transition. Special-casing that week isn’t the fix. The bug is structural: Date is a count of UTC milliseconds with no concept of “9 AM in Sydney,” so adding twenty-four hours to a millisecond count will eventually produce a DST bug. The cure isn’t smarter arithmetic; it’s a different type.

That type is Temporal, now native in Node 26. This lesson explains why Date is broken, introduces the five Temporal types that replace it, and installs the three rules the rest of the course relies on. Here, you learn which type to reach for.

The DST-drift bug isn’t a one-off. Date ships with eight design flaws in three clusters: its API surface, how it parses and reports values, and its precision. All eight share one root: Date overloads three jobs onto one shape, a UTC instant, a calendar date, and a wall clock , all stored as a millisecond count plus formatters that read the host’s local timezone. The type never says which role is in play, so the runtime guesses, and sometimes guesses wrong.

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 sit next to one-indexed days. setX mutates in place, so passing a Date to a function lets it silently alter the caller’s value. And there is no duration type: any span like 30 days becomes raw milliseconds, and a raw number can’t know that crossing a DST boundary turns “+24 hours” into 23 or 25 wall-clock hours.

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. A single space in place of the T flips the parse from UTC to local: the strings look nearly identical, but their timezone meaning differs. Code that takes a date string from a third party passes its tests on a UTC server, then breaks in a non-UTC environment.

The second is the Invalid Date sentinel . new Date('garbage') doesn’t throw; it returns a Date whose internal time is NaN, so every downstream call has to check isNaN(d.getTime()) first.

The third is toString, whose output depends on the host’s locale and timezone. The same date logs one way on a laptop, another on a Vercel build runner, a third in Sentry breadcrumbs. A value on the wire should be canonical, and toString isn’t.

These two lines run on a server whose timezone is 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());

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

A legacy two-digit-year heuristic reads any small year as two digits, so new Date(99, 0, 1) is 1999, not the year 99. The larger flaw is resolution: Postgres timestamptz stores microseconds and performance.now() returns sub-millisecond values, but Date stops at the millisecond, so a high-frequency log round-tripped through Date loses the order of events landing in the same millisecond.

The cure for all eight is the same: more types, not more methods. Give each meaning, instant, calendar date, and wall clock, its own type, and each type’s API can refuse the operations that don’t make sense for it.

Before moving on, sort the behaviours below. Some are Date misfeatures; some are normal language behaviour 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 is reference identity and the Unix epoch : not bugs, just how JavaScript objects and the clock work. The left column is what the replacement types eliminate.

Three real bugs, each mapped to the Temporal type that fixes it.

The DST reminder drift. Adding 7 * 86_400_000 advances seven UTC days, but a wall clock shifts an hour at a DST boundary, so the Monday email fires at 8 or 10 AM. The cure is Temporal.ZonedDateTime with add({ days: 1 }), which knows the user’s IANA tz and treats “a day” as a calendar step, not a fixed millisecond count.

The date-line invoice. An invoice created with dueDate: '2026-05-15' is stored as 2026-05-15T00:00:00Z. The UI reads it back as a Date in local time, so a user in Los Angeles (UTC-8) sees May 14 and the agreed due date silently moves a day earlier. The cure is Temporal.PlainDate: a calendar date has no time and no timezone, so it stays May 15 everywhere.

The audit-log timezone smear. A Server Component formats a row with new Date(row.createdAt).toLocaleString(). The server clock is UTC, so users in São Paulo, Berlin, and Honolulu all see the UTC string instead of their own time. The cure is Temporal.Instant: store it, send the ISO string over the wire, and format it at the boundary against the user’s timezone.

Temporal replaces Date’s one overloaded shape with five types, each named for a single meaning and each refusing the operations that don’t apply to it.

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 points ahead to unit 17; focus on the leftmost three for now.

Three properties of these types carry the rest of the architecture.

The type tells you what you have. Date lets you call due.getHours() on what you meant as a calendar date. PlainDate has no getHours(), so the ambiguity is refused at the API instead of surfacing downstream.

Immutability everywhere. No method mutates its receiver: instant.add({ minutes: 5 }) returns a new Instant and leaves instant untouched. This pairs with the const discipline you already use, so the value a name points at never changes underneath you.

Errors over sentinels. Temporal.Instant.from('garbage') throws a RangeError; with no Invalid Instant sentinel, bad input cannot propagate silently. Catch it at the parse seam, the same discipline you installed for JSON.parse earlier this chapter.

Where Temporal runs, and where it needs a polyfill

Section titled “Where Temporal runs, and where it needs a polyfill”

Temporal is native in Node 26 and needs a polyfill anywhere it isn’t. The course deploys to Node 26 on Vercel, so production runs Temporal natively.

Codebases on Node 24 LTS don’t. Two polyfills are mature: temporal-polyfill from FullCalendar, about 20 KB gzipped with near-complete spec compliance, and @js-temporal/polyfill, two to three times larger. The course picks temporal-polyfill for the smaller bundle.

Browser support is off the critical path. The app renders dates server-side and ships ISO 8601 strings, so the browser never needs a Temporal runtime to display a date.

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.
Where Temporal runs natively, and the course's reach in each runtime.

One rule the whole course depends on: exactly one file imports the polyfill, and every other file imports from that file. By convention it’s named lib/temporal.ts:

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

Pinned to Node 24, that two-line file is the whole thing. What matters is the seam : once Node 26 is the pinned runtime, those two lines collapse to one re-export of the native global. Every other file already imports Temporal from @/lib/temporal, so the swap touches one file.

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);

Every file reaches for Temporal from @/lib/temporal, never from temporal-polyfill or globalThis.Temporal directly. Mixing the native and polyfill Temporal in one process breaks instanceof and cross-instance from(): the two values look identical but 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 turns a Date from a third-party SDK into a Temporal.Instant the application can read. The arrow form, explicit return type, and single positional parameter 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);

The reverse codec, for the rare path where you hand a Date back to an SDK that requires one. Application code never calls either directly; the call sites live inside SDK adapters, lib/billing/ in unit 11 being the canonical example.

1 / 1

Some projects run Node 24 in one environment and Node 26 in another, such as CI on the LTS and local dev on the new runtime. For those, a one-line runtime guard makes the file work on either:

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

The course pins one Node version at a time, so the simpler re-export is the default.

The pivot is at the type level, so Date stays in scope for two cases.

SDK ingress. Third-party SDKs hand back Date instances. Stripe is the canonical example: its wire format is Unix seconds (10-digit integers), but the JavaScript SDK wraps them in Date, so your code receives subscription.created as a Date. The domain never reads it directly; it converts at the seam.

Stopwatch measurements. Date.now() and performance.now() answer “how many milliseconds did this take,” where only the gap between two reads counts. The result is a number, not a value that enters the domain. Prefer performance.now() when available: it is sub-millisecond and monotonic, so it doesn’t jump when the system clock corrects itself.

Everything else is Temporal. A value stored in Postgres, displayed in the UI, compared in domain logic, or scheduled by a background job is a Temporal type; a stopwatch or clock differential is Date.now() or performance.now().

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 need only the first function; the second is for SDKs like Stripe that hand you raw Unix seconds instead of a Date.

The conversion runs one direction here: a third-party Date becomes a domain Temporal.Instant. The reverse, dateFromInstant for SDKs that take a Date, follows the same principle: one converter, one place.

These three rules are what the rest of the course assumes.

  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: every Temporal operation returns a new instance.

  2. Convert SDK Date to Temporal.Instant at the seam with instantFromDate. No raw Date propagates inward from a third-party SDK.

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

One more habit: 2026 projects don’t install date-fns, dayjs, luxon, or moment, because the platform is the library.

Find the one line that breaks a rule.

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 look ahead to the full surface in unit 17.