The legacy Date object — and why it hurts
The JavaScript Date object is a 1995 design that has barely changed since. It models an instant as the number of milliseconds since the Unix epoch, which is fine. It also exposes a wall-clock API with getHours, setMonth, toLocaleString, and a constructor that accepts free-form strings — which is where the problems start. The current spec for the object is MDN's Date reference, and the design pre-dates everything we now know about zoned datetime APIs.
Three pitfalls catch every developer at least once, and most of them more than once.
// Pitfall 1: silent timezone shift between date-only and datetime strings.
new Date("2026-05-29").toISOString();
// "2026-05-29T00:00:00.000Z" -- parsed as UTC
new Date("2026-05-29T00:00").toISOString();
// "2026-05-28T23:00:00.000Z" -- parsed as local in UTC+1, off by a day
// Pitfall 2: mutability. Methods return void and mutate in place.
const d = new Date("2026-05-29T00:00:00Z");
const tomorrow = d;
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
console.log(d.toISOString()); // "2026-05-30T00:00:00.000Z" -- d also changed
// Pitfall 3: no timezone math. Date is always either UTC or "local"
// (whatever zone the runtime is in). To compute "9 AM in Tokyo" you
// need a library or Temporal -- the standard library cannot do it.
new Date("2026-05-29T09:00:00").toLocaleString("en-GB", {
timeZone: "Asia/Tokyo",
});
// renders FOR Tokyo but the underlying instant is still in your local zoneThe first pitfall is the most painful because the behaviour is specified, not a bug. ECMA-262 parses a date-only ISO string (2026-05-29) as UTC and a datetime string without an offset (2026-05-29T00:00) as local. The two strings look almost identical and produce instants a full timezone apart. Whenever a date string crosses a system boundary — an API response, a form input, a log line — there is a chance that someone parses it with the wrong rule. The only safe input is a string with an explicit offset.
The second pitfall, mutability, is why date code that "works in isolation" breaks the moment you start passing dates as function arguments. setUTCDate, setHours, setMonth, and friends all mutate the receiver and return void. A function that takes a date, computes "end of day", and returns it has silently rewritten the caller's value. The fix is to clone with new Date(d.getTime()) before every mutation, which every codebase eventually wraps in a helper, which is what date-fns and Luxon are for.
The third pitfall, the missing timezone math, is the deepest. Date is always either UTC or "local time" — meaning the zone of the runtime process. There is no way to ask Date for "9 AM in Tokyo tomorrow" without first computing what 9 AM Tokyo is in UTC, then constructing a UTC Date, then formatting it back to Tokyo wall-clock for display. toLocaleString can render any zone, but the underlying instant is still in your local zone, and arithmetic on it ignores the display zone entirely.
Every JavaScript date library you have heard of exists to paper over these three gaps. Temporal is the standards-track fix.
Temporal API — the future
Temporal is the long-awaited replacement for Date. It reached TC39 Stage 3 in early 2024 and is now shipping in Firefox release builds, in Node 22 behind the --harmony-temporal flag, and in the temporal-polyfill npm package for everyone else. caniuse tracks the rollout. As of mid-2026, Chrome and Safari support is in active development but not yet enabled by default, which is the only reason you cannot use Temporal in production browser code today.
The proposal is large, but you will spend 95% of your time in three types:
// Temporal proposal (Stage 3, TC39). Node 22 ships behind --harmony-temporal.
// Polyfill: `npm install temporal-polyfill` and `import "temporal-polyfill/global"`.
// Three core types you will use 95% of the time:
// 1) An absolute instant in time, nanosecond precision, no zone.
const start = Temporal.Instant.from("2026-05-29T15:43:00Z");
// 2) A wall-clock date with no time and no zone. Use for birthdays,
// holidays, invoice due dates -- anything that is genuinely date-only.
const dob = Temporal.PlainDate.from("1995-03-14");
// 3) The headline type: an instant + an IANA zone, so the wall-clock
// arithmetic is correct across DST.
const meeting = Temporal.ZonedDateTime.from(
"2026-05-29T09:00:00[America/Los_Angeles]"
);
// All three are immutable. Methods return new instances.
const nextDay = meeting.add({ days: 1 });
console.log(meeting.toString()); // unchanged
// And the arithmetic respects DST automatically:
const tueAfterDst = Temporal.ZonedDateTime
.from("2026-03-07T09:00[America/Los_Angeles]")
.add({ days: 7 });
console.log(tueAfterDst.toString());
// 2026-03-14T09:00:00-07:00[America/Los_Angeles] -- still 9 AM localTemporal.Instant is an absolute moment with nanosecond precision and no zone — the equivalent of a UTC epoch number, but with type safety. Temporal.PlainDate is a calendar date with no time and no zone, for anything that is genuinely date-only (birthdays, holidays, invoice due dates). Temporal.ZonedDateTime is the headline type: an instant tagged with an IANA zone, where arithmetic respects DST automatically and timezone conversion is a method call. There are a handful of other types (PlainTime, PlainDateTime, PlainYearMonth, Duration) but you can build a real app with just those three.
The design wins that matter: every type is immutable, so the clone-before-mutating cargo cult disappears. The constructor accepts only unambiguous input, so the date-only vs datetime UTC-vs-local trap disappears. Arithmetic on ZonedDateTime respects the IANA zone, so "every Tuesday at 9 AM Pacific" does the right thing across DST without a library. And the API is consistent with what Luxon, Joda-Time, and Java's java.time have proven works.
For new server-side code on Node 22 today, the polyfill is production-grade. For browser code, wait until Chrome and Safari ship stable support, or use temporal-polyfill/global to install a global Temporal that future-compatible code can target. The migration cost when the native API lands is removing one line from your entrypoint.
date-fns vs Day.js vs Luxon vs Moment
Four libraries cover almost every JavaScript date dependency in the wild. They differ in three things that matter: bundle size, immutability, and how much zone and locale support comes in the box.
| Library | Bundle (min+gzip) | Tree-shakeable | Immutable | Zones in core | Status |
|---|---|---|---|---|---|
| date-fns | ~13 KB typical | Yes (function-per-import) | Effectively (no Date wrapper) | Via date-fns-tz | Active, v4 in 2024 |
| Day.js | ~2 KB core | Plugin model (opt-in features) | Yes | Via timezone plugin | Active |
| Luxon | ~75 KB | Limited | Yes (DateTime is immutable) | Yes, via Intl | Active |
| Moment | ~230 KB | No | No (mutable) | Via moment-timezone (+185 KB) | Legacy (since 2020) |
// Same operation in each library: "add 7 days to a date".
// date-fns -- tree-shakeable, immutable, no Date wrapper
import { addDays, format, parseISO } from "date-fns";
const d1 = addDays(parseISO("2026-05-29T15:43:00Z"), 7);
format(d1, "yyyy-MM-dd HH:mm");
// Day.js -- 2 KB core, plugin model
import dayjs from "dayjs";
const d2 = dayjs("2026-05-29T15:43:00Z").add(7, "day");
d2.format("YYYY-MM-DD HH:mm");
// Luxon -- rich, zone-first, immutable
import { DateTime } from "luxon";
const d3 = DateTime.fromISO("2026-05-29T15:43:00Z", { zone: "utc" })
.plus({ days: 7 });
d3.toFormat("yyyy-MM-dd HH:mm");
// Temporal (future / polyfill today)
const d4 = Temporal.Instant
.from("2026-05-29T15:43:00Z")
.add({ hours: 24 * 7 });
d4.toString();The pragmatic recommendation, sharpened against thousands of greenfield projects:
- date-fns for most apps. The tree-shaken function-per-import model means a typical app ships under 20 KB of date code even with
date-fns-tzadded. date-fns docs are excellent and the v4 release in 2024 cleaned up the timezone story. - Luxon for timezone-heavy apps (calendar, scheduling, travel). The full bundle cost is real but the rich immutable
DateTimewith first-class IANA zones is best-in-class today. Luxon docs. When Temporal stabilizes, Luxon migration is mostly mechanical. - Day.js for tiny bundles, marketing sites, anywhere you mostly format and add days. Skip it if you need zones — the timezone plugin pulls in the IANA tzdata and the bundle saving evaporates.
- Moment for nothing new. The team marked it a legacy project in 2020. Existing code keeps working; new code starts elsewhere.
Timezone handling in JavaScript
The single most asked date question in JavaScript is some variant of "give me 9 AM tomorrow in Tokyo". The standard library cannot answer it directly. new Date("2026-05-30T09:00") constructs 9 AM in your zone, not Tokyo's. toLocaleString("en-US", { timeZone: "Asia/Tokyo" }) renders an instant in Tokyo's zone but cannot construct one. The time zones explained guide covers the underlying IANA tzdb model.
Two libraries solve the construction side cleanly: date-fns-tz for date-fns apps and luxon for everything bigger. The patterns:
// date-fns-tz -- the timezone companion to date-fns.
import { fromZonedTime, formatInTimeZone, toZonedTime } from "date-fns-tz";
// "9 AM Tokyo tomorrow" -- the question new Date() cannot answer.
const tokyoNineAm = fromZonedTime(
"2026-05-30T09:00:00",
"Asia/Tokyo",
);
console.log(tokyoNineAm.toISOString());
// "2026-05-30T00:00:00.000Z" -- the actual UTC instant
// Render an instant in any zone without mutating it:
formatInTimeZone(tokyoNineAm, "America/New_York", "yyyy-MM-dd HH:mm zzz");
// "2026-05-29 20:00 EDT"
// toZonedTime gives you a "fake Date" whose getHours returns the zoned value --
// useful for component props but a foot-gun if you forget what it is.
const zoned: Date = toZonedTime(tokyoNineAm, "Asia/Tokyo");fromZonedTime takes a wall-clock string and an IANA zone, and returns the actual UTC instant. formatInTimeZone takes an instant and an IANA zone, and renders the wall-clock without mutating the instant. Those two primitives cover 90% of zone work.
toZonedTime is the one to be careful with. It returns a Date whose getHours, getMinutes etc. return the zoned values — which is convenient for passing to a component as props, but catastrophic if you forget what it is and pass it to .toISOString() (which then renders the "fake" local time as if it were UTC). Treat toZonedTime output as a display value only and never feed it back into date math.
Temporal removes the foot-gun entirely. ZonedDateTime is the type the language always needed:
// The same operation in Temporal -- this is the API the standard
// library should always have had.
const tokyoNineAm = Temporal.ZonedDateTime.from({
year: 2026, month: 5, day: 30, hour: 9, timeZone: "Asia/Tokyo",
});
// Convert to another zone without losing precision:
const nyView = tokyoNineAm.withTimeZone("America/New_York");
nyView.toString();
// 2026-05-29T20:00:00-04:00[America/New_York]
// Extract the underlying UTC instant:
tokyoNineAm.toInstant().toString();
// 2026-05-30T00:00:00ZFor one-off conversions and verification of tricky offsets, the time zone converter covers the IANA zones every modern runtime knows about.
Parsing dates safely
Date.parse() is the single most over-trusted method on the platform. ECMA-262 only requires it to handle the ISO 8601 extended form correctly — every other format is implementation-defined, which means a string that parses on V8 may return NaN on JavaScriptCore, and vice-versa. The MDN page on Date.parse is explicit: "strongly recommended that you not use it, due to differences in behavior between browsers".
The five common formats in production, and how to handle each safely with date-fns:
import { parse, parseISO, isValid } from "date-fns";
// 1) ISO 8601 / RFC 3339 -- the only format you should accept on a wire.
const a = parseISO("2026-05-29T15:43:00Z"); // OK
const b = parseISO("2026-05-29T15:43:00+01:00"); // OK
isValid(a) && isValid(b); // true
// 2) Custom formats -- be explicit. parse() takes a format string and
// a reference date for any fields the input does not supply.
parse("29/05/2026", "dd/MM/yyyy", new Date()); // UK style
parse("05/29/2026", "MM/dd/yyyy", new Date()); // US style
// 3) Slash dates with TWO-digit year -- always normalize first.
const yy = "29/05/26";
const yyyy = yy.replace(/(\d{2})$/, "20$1");
parse(yyyy, "dd/MM/yyyy", new Date());
// 4) Unix epoch -- detect and branch.
const raw: string = "1748533380";
const epoch = Number(raw);
const date = epoch < 1e12 ? new Date(epoch * 1000) : new Date(epoch);
// 5) Human strings ("yesterday at 3 PM") -- do not. Reject at the boundary
// or use a dedicated NLP library like chrono-node. The Date constructor's
// "best effort" parsing of these is implementation-defined and will burn you.
new Date("yesterday"); // Invalid Date in every modern engineThe pattern that keeps you out of trouble: at every system boundary, parse to a concrete type using a known-format helper (parseISO, parse(format), fromZonedTime), and store the result. The Date constructor accepting a free-form string is a bug magnet — every implementation interprets "May 29 2026" or "29-05-2026" differently, and the differences are silent.
For one-off format checks and to round-trip an unfamiliar timestamp, the date format converter and epoch converter handle the formats this guide covers.
Formatting for users
Hand-formatted date strings (`${day}/${month}/${year}`) are an anti-pattern in any app that ships in more than one locale. The right primitive is Intl.DateTimeFormat, which is part of the ECMA-402 internationalization standard and is supported in every modern engine. It handles locale (US English vs Indian English vs Japanese), calendar system (Gregorian vs Japanese vs Hebrew), numbering system (Latin vs Arabic digits), and timezone correctly, and it caches efficiently when you reuse the formatter instance.
// Intl.DateTimeFormat is the right primitive. It handles locale,
// calendar, numbering, and zone correctly -- and it caches efficiently
// when you reuse the formatter instance.
const fmt = new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/London",
});
const d = new Date("2026-05-29T15:43:00Z");
fmt.format(d); // "29 May 2026, 16:43"
// Relative time -- "3 days ago", "in 2 hours".
const rel = new Intl.RelativeTimeFormat("en-GB", { numeric: "auto" });
rel.format(-1, "day"); // "yesterday"
rel.format(2, "hour"); // "in 2 hours"
// SSR pitfall: locale negotiation. The server's Accept-Language often differs
// from the browser's resolvedOptions().locale. Pass the locale explicitly from
// request headers in Next.js server components, do NOT rely on
// new Intl.DateTimeFormat() with no locale argument -- it picks up the
// server's default (usually "en-US"), and you get hydration mismatches.Two pitfalls bite people on the server. First, locale negotiation: if you create new Intl.DateTimeFormat() with no locale argument on a Next.js server component, it picks up whatever locale the server process is configured for (usually en-US), which mismatches what the user's browser will use when it hydrates. Result: hydration warnings and wrong dates on the first paint. Always pass the locale explicitly, either from Accept-Language headers or from a user preference column.
Second, SSR-safe formatting: when you format a date for "today" on the server, the server and the client will agree only if both use the same timezone. Set TZ=UTC on your server, format with an explicit timeZone option in Intl.DateTimeFormat, and the hydration mismatch goes away.
For deeper reading on which format to emit when, see the time format style guide — the format-decision companion to this guide.
DST traps in code
The classic DST bug is the "every Tuesday at 9 AM" recurring event that silently moves an hour every March and November. The mechanism is always the same: somewhere in the code, a date is advanced by 7 * 24 * 60 * 60 * 1000 milliseconds, which is correct in UTC and wrong in any zoned wall-clock sense. On DST day a real Tuesday-at-9-AM is 23 or 25 hours after the previous one, not 24, and naive arithmetic accumulates the error.
// THE bug: schedule "every Tuesday at 9 AM" by adding 7 * 24 hours.
import { addDays } from "date-fns";
let next = new Date("2026-03-03T09:00:00-08:00"); // Tue 3 Mar PST
for (let i = 0; i < 4; i++) {
next = new Date(next.getTime() + 7 * 24 * 60 * 60 * 1000);
console.log(next.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }));
}
// Tue Mar 10 9:00:00 AM PDT <- DST started 8 Mar, clock is now 8:00 AM PST equivalent
// Tue Mar 17 9:00:00 AM PDT
// ... but the WALL-CLOCK shifted: meeting moved from 9 AM to "9 AM after DST"
// THE fix: zoned arithmetic.
import { fromZonedTime, toZonedTime } from "date-fns-tz";
let cursor = fromZonedTime("2026-03-03T09:00:00", "America/Los_Angeles");
for (let i = 0; i < 4; i++) {
// Compute next instant by adding 7 days in the LOCAL zone, not UTC.
const local = toZonedTime(cursor, "America/Los_Angeles");
local.setDate(local.getDate() + 7);
cursor = fromZonedTime(local.toISOString().slice(0, 19), "America/Los_Angeles");
}
// OR with Temporal -- zoned arithmetic is the default:
let z = Temporal.ZonedDateTime
.from("2026-03-03T09:00[America/Los_Angeles]");
for (let i = 0; i < 4; i++) {
z = z.add({ days: 7 });
console.log(z.toString()); // still 9:00 local on every Tuesday
}The fix in date-fns is to advance in the local zone with toZonedTime + setDate + fromZonedTime, or to use add with a duration that respects wall-clock semantics. The fix in Temporal is to use ZonedDateTime.add, which handles DST automatically. The fix in your data model is to never persist "9 AM Pacific every Tuesday" as a list of UTC instants — persist the rule (start date, wall-clock time, IANA zone, recurrence pattern) and recompute the next instant from the rule at read time.
The other DST trap is the "fall back" doubled hour: between 01:00 and 02:00 on a fall transition day, every wall-clock minute happens twice. A booking at "01:30 New York" on 1 November 2026 is ambiguous between EDT and EST. Temporal's ZonedDateTime.from takes a disambiguation option (compatible, earlier, later, reject) to handle this; date-fns-tz defaults to "the first occurrence" silently. For appointment systems, either reject bookings in the doubled hour or surface the choice in the UI.
For the upcoming DST dates that will trip apps in 2026, the DST 2026 survival guide lists every transition by region.
Performance
Date operations look cheap and usually are. They become expensive in three situations: high-frequency event handlers, hot render loops with hundreds of rows, and SSR pipelines that format thousands of timestamps per request. The cost model is worth knowing.
// In a hot loop, the cost of date operations matters more than you would expect.
// SLOW: new Date() with a string -- string parsing on every call.
for (let i = 0; i < 1_000_000; i++) {
const d = new Date("2026-05-29T15:43:00Z"); // ~3x slower than Date.now()
}
// FAST: Date.now() for a current timestamp.
for (let i = 0; i < 1_000_000; i++) {
const t: number = Date.now();
}
// FASTER for sub-millisecond timing -- monotonic, not wall-clock.
const start = performance.now();
doWork();
const elapsedMs = performance.now() - start;
// Cache locale formatters. Each new Intl.DateTimeFormat() builds an
// internal CLDR table; reusing one is up to 10x faster.
const fmt = new Intl.DateTimeFormat("en-GB", { timeStyle: "short" });
for (const event of events) {
out.push(fmt.format(event.at)); // reuses the cached formatter
}
// For high-frequency events, store epoch ms (or ns) as a number and only
// convert to Date or ZonedDateTime at the display boundary.
type Event = { at: number; payload: string }; // `at` is epoch msThe headline cost on V8 (the engine in Chrome and Node): Date.now() is a single syscall returning a number, taking nanoseconds. new Date() with no arguments is similar. new Date(string) runs the ISO parser and is 2-5x slower depending on the string shape — fine in isolation, measurable in a tight loop. performance.now() returns a monotonic timestamp that does not jump backwards if the system clock is adjusted, which is the right choice for any duration measurement.
Intl.DateTimeFormat instances are expensive to construct (CLDR table lookup, locale negotiation, internal caches), and cheap to use. The wrong pattern is constructing a new formatter for every row of a list; the right pattern is constructing one above the loop and calling .format() per row. Benchmark on your data, but reuse can be 10x faster in render-heavy code.
For high-frequency event streams (analytics, telemetry, market data), the cheapest representation is a plain epoch number. Store the number, do arithmetic on numbers, and only convert to Date, ZonedDateTime, or a formatted string at the display boundary. Whole pipelines run measurably faster when they never construct a Date object until the last hop.
Testing date code
Date tests fail in two characteristic ways: they pass on the developer's machine and fail in CI because the timezones differ, and they pass today and fail tomorrow because new Date() returned a different value. Both have the same fix: pin the clock and pin the timezone.
// Vitest -- the modern default for new code.
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
describe("billing window", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-29T15:43:00Z")); // freeze Date.now and new Date()
});
afterEach(() => {
vi.useRealTimers();
});
it("rolls over at midnight UTC", () => {
expect(currentBillingDay()).toBe("2026-05-29");
vi.setSystemTime(new Date("2026-05-30T00:00:00Z"));
expect(currentBillingDay()).toBe("2026-05-30");
});
});
// Jest -- the same pattern with the legacy API.
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2026-05-29T15:43:00Z"));
});
afterEach(() => {
jest.useRealTimers();
});
// CI: pin the timezone. Set TZ=UTC in your CI workflow so any `new Date()`
// call that does forget to specify a zone behaves consistently:
//
// env:
// TZ: UTC
//
// And NEVER snapshot a value of `new Date()` -- the snapshot will be wrong
// the next time the tests run. Snapshot the deterministic output of your
// function with a frozen system time.Vitest's vi.useFakeTimers() and vi.setSystemTime() replace Date.now, new Date(), and the timer functions with a deterministic clock. Jest has the same API under jest.useFakeTimers / jest.setSystemTime. Whichever you use, set the time in beforeEach and restore in afterEach so each test starts from a known instant.
For the timezone half of the problem, set TZ=UTC in your CI workflow. It tells the JavaScript runtime to use UTC for any unzoned operation, and it guarantees CI behaves the same as a UTC production server. On Vercel, Netlify, Cloudflare Workers, and Lambda, the runtime is already UTC by default — the surprise comes when your dev machine is set to America/Los_Angeles or Europe/London and you write a test that implicitly depends on it.
And never snapshot a value of new Date(). The snapshot will be wrong the next time the tests run. Snapshot the deterministic output of your function with a frozen system time, not the timestamp itself.
Quick decision tree
"Which library should I reach for" is the question every team eventually asks. The short answer is below; the long answer is in the comparison table further up.
// "Which library should I reach for?" -- the short version.
if (need === "single date math (add days, format, parse)") {
// Smallest footprint, tree-shaken, immutable enough.
use("date-fns");
}
if (need === "tiny bundle, mostly display") {
// 2 KB core + plugins for what you use.
use("dayjs");
}
if (need === "timezone-heavy app (calendar, scheduling, travel)") {
// Best-in-class zoned API today. Larger bundle, worth it.
use("luxon");
// ...with the option to migrate to Temporal once stable.
}
if (need === "complex zoned recurrence + greenfield Node 22 server") {
// Future-proof, learn the new API now.
use("temporal-polyfill");
}
if (need === "your codebase already has Moment") {
// Plan a migration. Mechanically simple, prevents bundle bloat.
migrate("moment", "date-fns");
}
if (need === "embedded / tiny / no library at all") {
// The Date object + Intl.DateTimeFormat will get you surprisingly far.
use("platform");
}The three-line version, sharpened against production: date-fns for almost everything that does some date math and formatting. Luxon when timezones are the application, not a feature. Temporal when you are writing server code on Node 22 today and want to be on the right side of the migration when browsers catch up. Day.js for tiny bundles; never Moment for anything new. The rule that matters more than the choice: pick one library per codebase, never mix Moment and date-fns and Luxon in the same app — the cognitive cost and the bundle bloat compound silently.