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.
Why Date keeps causing production bugs
Section titled “Why Date keeps causing production bugs”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.
The API traps
Section titled “The API traps”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 DSTZero-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.
The semantic traps
Section titled “The semantic traps”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 prodThe 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());On a UTC machine both lines print the same instant. Move the same code to a server in any non-UTC zone and the second line shifts. The ISO date-only form ('2026-05-15') is specified to parse as UTC. The space-separated form ('2026-05-15 00:00') is parsed as local time, then converted back to UTC for the ISO output. A single character — the space versus the T — flips the timezone semantics. That’s the bug class: format-determined timezone parsing, where the wire format silently changes meaning across deploy environments.
The precision traps
Section titled “The precision traps”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 eitherThe 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.
new Date('garbage') returns an Invalid Date instead of throwingd.setMonth(5) mutates the caller’s valueT in an ISO string flips UTC to localnew Date(99, 0, 1) is the year 199924 * 60 * 60 * 1000 breaks across DSTJSON.stringify(new Date()) produces an ISO 8601 string (via toJSON)Date.now() returns milliseconds since the Unix epochDate 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 three canonical production bugs
Section titled “The three canonical production bugs”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.
Temporal, the type catalog
Section titled “Temporal, the type catalog”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.
The Postgres column on the right is a forward pointer to unit 17. For now, focus on the leftmost three columns.
Each type has a static factory. The constructor is hidden, so you build a value through from, fromEpochMilliseconds, or Temporal.Now.
const now = Temporal.Now.instant();const due = Temporal.PlainDate.from('2026-05-15');const schedule = Temporal.ZonedDateTime.from( '2026-05-15T09:00:00[Australia/Sydney]',);const sla = Temporal.Duration.from({ days: 30 });const local = Temporal.PlainDateTime.from('2026-05-15T17:00');The from method accepts ISO 8601 strings or option objects. The bracketed [Australia/Sydney] in the third line is the standard ISO 8601 extension Temporal uses to attach a timezone to a date-time.
new Date('2026-05-15') UTC midnight — ambiguous "date". Temporal.PlainDate.from('2026-05-15') Calendar date — unambiguous. Date.now() Milliseconds since the epoch. Temporal.Now.instant() Nanosecond-precision instant. new Date(d.getTime() + 30 * 86_400_000) Raw milliseconds — breaks across DST. d.add({ days: 30 }) Calendar-aware — DST-safe. new Date('garbage') Silent Invalid Date sentinel. Temporal.Instant.from('garbage') Throws RangeError at the parse seam. date.setMonth(5) Mutates the caller in place. date.with({ month: 5 }) Returns a new instance. 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.
The runtime story, May 2026
Section titled “The runtime story, May 2026”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.
The lib/temporal.ts seam
Section titled “The lib/temporal.ts seam”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:
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.
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.
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.
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.
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.
Date stays at the seam
Section titled “Date stays at the seam”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.
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.
import { Temporal } from '@/lib/temporal';
type Subscription = { id: string; currentPeriodEnd: Temporal.Instant;};
export const isExpired = ( sub: Subscription, now: Temporal.Instant,): boolean => Temporal.Instant.compare(sub.currentPeriodEnd, now) < 0;No Date in sight. The adapter at the seam is the only place that touches the SDK’s Date wrappers, and in unit 12 that adapter lives in lib/billing/. Domain code reads Temporal.Instant exclusively.
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 three rules
Section titled “The three rules”The whole lesson comes down to three rules. Memorize them, because the rest of the course assumes them.
-
Never
new Date(year, month, day)ordate.setX(...)in application code. Construction goes throughTemporal.PlainDate.from('2026-05-15')orTemporal.Now.instant(). Mutation never comes up, because every Temporal operation returns a new instance. -
Convert SDK
DatetoTemporal.Instantat the seam. UseinstantFromDatefromlib/temporal.ts. No rawDatepropagates inward from a third-party SDK. -
Import
Temporalfrom@/lib/temporal. Never fromtemporal-polyfilldirectly, never fromglobalThis.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.
Practice and recall
Section titled “Practice and recall”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.
Temporal.PlainDateTemporal.InstantTemporal.ZonedDateTimeTemporal.DurationTemporal.PlainDateTimeThen 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.Two senior reasons one wrong line is doubly wrong.
First, the API trap: new Date(2026, 5, 15) is June 15, 2026 — the month argument is zero-indexed, so 5 means June, not May. The equivalent Temporal.PlainDate.from({ year: 2026, month: 6, day: 15 }) is unambiguous because the field name is month and the value is one-indexed.
Second, the type trap: a signup anniversary is calendar-day semantics — “May 15, every year” — not an instant in real time. The right type is Temporal.PlainDate, even if you’d gotten the month right.
External resources
Section titled “External resources”These point ahead to the surface you’ll meet in unit 17.
Reference for the Temporal type surface, including the construction, arithmetic, and formatting API.
The spec champions' repo. The cookbook under /docs/cookbook/ has the canonical recipes for DST, calendars, and durations.
The course's polyfill choice: ~20 KB gzipped, near-complete spec compliance, the default on Node 24.
Bloomberg's Jason Williams on how Temporal moved from Stage 1 (2017) to Stage 4 — the why behind every type.
Live browser support matrix. Confirms which engines ship Temporal natively and which still need the polyfill.