date-fns v4 vs Temporal API vs Day.js: Date Handling in 2026
TL;DR
date-fns v4 (~13KB tree-shaken, functional, TypeScript-first) is the 2026 default for most JavaScript date work. The Temporal API (TC39 Stage 3, polyfill available as @js-temporal/polyfill) is the future web standard — immutable by design, timezone-correct by default — but adds ~60KB polyfill today. Day.js (~2KB core + plugins) is still the right choice when migrating from Moment.js or when you want a chaining API with a minimal footprint. Avoid Moment.js (70KB, mutable, deprecated) and Luxon (24KB, largely superseded by date-fns-tz for timezone work).
Key Takeaways
- date-fns v4 ~30M weekly downloads — Temporal polyfill ~500K — Day.js ~18M
- date-fns v4: pure functions, tree-shakable, TypeScript, ~13KB for typical usage
- Temporal API: Stage 3 standard, immutable, DST-correct, use polyfill now
- Day.js: 2KB core, Moment.js-compatible chaining API, plugin ecosystem
- No one handles DST perfectly without Temporal —
Date+ arithmetic has edge cases - Temporal will eventually be zero-cost — when browsers ship it natively (~2026–2027)
Why JavaScript Date Handling Is Still Hard in 2026
The built-in Date object has been broken since the beginning:
// Date has no timezone awareness — it always stores UTC internally
const d = new Date('2026-03-09');
console.log(d.getDate()); // Returns 8 or 9 depending on your system timezone!
// DST arithmetic with Date is unreliable
const springForward = new Date('2026-03-08T02:30:00'); // In a 2am→3am DST gap
// What is "1 hour before" this? Date gets it wrong for wall-clock times
// Date objects are mutable — this modifies the original
const start = new Date();
start.setDate(start.getDate() + 7); // No new object created; start is now mutated
All three libraries solve these problems in different ways.
date-fns v4: The 2026 Default
date-fns v4 (released in late 2024) brought TypeScript improvements, strict parsing, and module restructuring. It remains the best choice for most TypeScript projects.
Core Operations
import {
format, formatDistance, formatRelative,
add, sub, differenceInDays, differenceInHours,
isAfter, isBefore, isSameDay,
startOfDay, endOfDay, startOfWeek, startOfMonth, endOfMonth,
parseISO, parse, isValid,
eachDayOfInterval, isWeekend,
} from 'date-fns';
const now = new Date();
// Formatting
format(now, 'yyyy-MM-dd'); // "2026-03-09"
format(now, 'MMMM d, yyyy'); // "March 9, 2026"
format(now, 'h:mm a'); // "3:45 PM"
format(now, 'EEEE'); // "Monday"
format(now, "yyyy-MM-dd'T'HH:mm:ssXXX"); // ISO 8601 with offset
// Arithmetic — always returns a new Date (immutable)
const nextWeek = add(now, { weeks: 1 });
const lastMonth = sub(now, { months: 1 });
const inThreeHours = add(now, { hours: 3, minutes: 30 });
// Relative time
formatDistance(new Date('2026-03-01'), now, { addSuffix: true });
// "8 days ago"
// Comparisons
isAfter(nextWeek, now); // true
isBefore(lastMonth, now); // true
isSameDay(now, new Date()); // true
differenceInDays(nextWeek, now); // 7
// Date ranges
const weekDays = eachDayOfInterval({
start: startOfWeek(now, { weekStartsOn: 1 }), // Monday
end: endOfMonth(now),
}).filter(d => !isWeekend(d));
// Parsing
const isoDate = parseISO('2026-03-09T15:45:00Z');
const customDate = parse('09/03/2026', 'dd/MM/yyyy', new Date());
console.log(isValid(customDate)); // true
Timezone Handling with date-fns-tz
import { toZonedTime, fromZonedTime, formatInTimeZone } from 'date-fns-tz';
const utcDate = new Date('2026-03-09T15:45:00Z');
const userTz = 'America/New_York';
// Format a UTC date as if it were in a specific timezone
formatInTimeZone(utcDate, userTz, 'yyyy-MM-dd HH:mm zzz');
// "2026-03-09 10:45 EST"
// Convert UTC → zoned Date object for further operations
const localDate = toZonedTime(utcDate, userTz);
format(localDate, 'h:mm a'); // "10:45 AM"
// Convert user input (zoned) → UTC for storage
const userInput = '2026-03-09 10:45';
const localParsed = parse(userInput, 'yyyy-MM-dd HH:mm', new Date());
const utcForStorage = fromZonedTime(localParsed, userTz);
What Changed in date-fns v4
- Strict parsing by default —
parseISO('2026-03-09T')now throws instead of returning an invalid date silently - TypeScript improvements — better generic inference, stricter return types
- FP module restructured —
date-fns/fpsubmodule with curried functions for functional pipelines - Native Temporal interop — the library is being prepared for the Temporal API
Temporal API: The Future Standard
The Temporal API is a TC39 Stage 3 proposal — meaning it is essentially finalized and browser implementations are in progress. You can use it today via the official polyfill.
npm install @js-temporal/polyfill
The Key Types
Temporal introduces distinct types for different concepts, eliminating the ambiguity of Date:
import { Temporal } from '@js-temporal/polyfill';
// PlainDate — a date without time or timezone (birthdays, holidays)
const birthday = Temporal.PlainDate.from('1990-06-15');
const today = Temporal.Now.plainDateISO();
const age = today.since(birthday, { largestUnit: 'years' }).years;
console.log(`Age: ${age}`);
// PlainTime — a time without date or timezone (opening hours)
const openAt = Temporal.PlainTime.from('09:00:00');
const closeAt = Temporal.PlainTime.from('17:30:00');
const hoursOpen = openAt.until(closeAt, { largestUnit: 'hours' });
// PlainDateTime — date + time without timezone (scheduling within a single timezone)
const meeting = Temporal.PlainDateTime.from('2026-03-15T14:00:00');
const meetingPlusTwoHours = meeting.add({ hours: 2 });
// ZonedDateTime — the full type with timezone awareness
const now = Temporal.Now.zonedDateTimeISO('America/New_York');
console.log(now.toString());
// "2026-03-09T10:45:30-05:00[America/New_York]"
// Instant — a specific moment in time (UTC, for machine timestamps)
const timestamp = Temporal.Now.instant();
console.log(timestamp.toString()); // "2026-03-09T15:45:30Z"
DST-Correct Arithmetic
This is where Temporal truly shines. The built-in Date object gets DST transitions wrong:
// DST "spring forward" in US Eastern: 2am → 3am on March 8, 2026
const beforeDST = Temporal.ZonedDateTime.from('2026-03-08T01:30:00[America/New_York]');
// Add 1 hour — correctly skips the DST gap
const afterDST = beforeDST.add({ hours: 1 });
console.log(afterDST.toString());
// "2026-03-08T03:30:00-04:00[America/New_York]" ← correctly 3:30 AM, not 2:30 AM
// Compare to what Date does:
const dateBeforeDST = new Date('2026-03-08T01:30:00-05:00');
const dateAfterDST = new Date(dateBeforeDST.getTime() + 3600000);
// This returns the right UTC time but getHours() returns wrong wall-clock time
Formatting and Parsing
import { Temporal, Intl } from '@js-temporal/polyfill';
const now = Temporal.Now.zonedDateTimeISO('Europe/London');
// toLocaleString — uses Intl.DateTimeFormat under the hood
now.toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' });
// "Monday, 9 March 2026 at 15:45"
now.toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
// "March 9, 2026"
// PlainDate parsing
const date = Temporal.PlainDate.from('2026-03-09');
// Throws on invalid input (unlike Date which silently returns Invalid Date)
Duration Type
// Temporal.Duration — a first-class duration type
const duration = Temporal.Duration.from({ years: 1, months: 2, days: 15 });
const future = Temporal.Now.plainDateISO().add(duration);
// Duration arithmetic
const twoHours = Temporal.Duration.from({ hours: 2 });
const thirtyMinutes = Temporal.Duration.from({ minutes: 30 });
const twoHoursThirty = twoHours.add(thirtyMinutes);
// { hours: 2, minutes: 30 }
Day.js: The Moment.js Successor
Day.js offers a nearly identical API to Moment.js at 2KB instead of 70KB. It is the fastest migration path from Moment.js and still the right choice when you want a chaining API.
Core Usage
import dayjs from 'dayjs';
// Moment.js-compatible API
dayjs().format('YYYY-MM-DD'); // "2026-03-09"
dayjs().format('MMMM D, YYYY'); // "March 9, 2026"
dayjs('2026-03-01').isAfter(dayjs()); // false
// Arithmetic — Day.js uses immutable chaining
const nextWeek = dayjs().add(1, 'week');
const lastMonth = dayjs().subtract(1, 'month');
const endOfMonth = dayjs().endOf('month');
// Comparison
dayjs('2026-03-09').isBefore(dayjs('2026-03-10')); // true
dayjs('2026-03-09').isSame(dayjs('2026-03-09'), 'day'); // true
Plugin System
Day.js core is minimal by design — extend it with plugins:
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import duration from 'dayjs/plugin/duration';
dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(customParseFormat);
dayjs.extend(weekOfYear);
dayjs.extend(duration);
// Relative time
dayjs('2026-03-01').fromNow(); // "8 days ago"
dayjs('2026-04-01').toNow(); // "in 23 days"
// Timezone
dayjs().tz('America/New_York').format('h:mm A');
dayjs.utc('2026-03-09T15:45:00Z').tz('Asia/Tokyo').format('YYYY-MM-DD HH:mm');
// "2026-03-10 00:45"
// Duration
const dur = dayjs.duration({ hours: 2, minutes: 30 });
dur.humanize(); // "3 hours" (approximate)
// Custom parse format
dayjs('09/03/2026', 'DD/MM/YYYY').format('YYYY-MM-DD'); // "2026-03-09"
Side-by-Side Comparison
The same task in all three libraries:
// ── TASK: Format today's date ──────────────────────────────────────────
// date-fns v4
import { format } from 'date-fns';
format(new Date(), 'MMMM d, yyyy'); // "March 9, 2026"
// Temporal API
Temporal.Now.plainDateISO().toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
// Day.js
dayjs().format('MMMM D, YYYY'); // "March 9, 2026"
// ── TASK: Add 1 month to a date ────────────────────────────────────────
// date-fns v4
import { add } from 'date-fns';
add(new Date('2026-01-31'), { months: 1 });
// → 2026-02-28 (month-end clamping)
// Temporal API
Temporal.PlainDate.from('2026-01-31').add({ months: 1 });
// → 2026-02-28 (same behavior)
// Day.js
dayjs('2026-01-31').add(1, 'month').toDate();
// → 2026-02-28
// ── TASK: Convert UTC to user's timezone ───────────────────────────────
// date-fns-tz
import { formatInTimeZone } from 'date-fns-tz';
formatInTimeZone(new Date('2026-03-09T15:45:00Z'), 'America/Chicago', 'h:mm a');
// "9:45 AM"
// Temporal API
Temporal.Instant.from('2026-03-09T15:45:00Z')
.toZonedDateTimeISO('America/Chicago')
.toLocaleString('en-US', { hour: 'numeric', minute: 'numeric' });
// "9:45 AM"
// Day.js + timezone plugin
dayjs.utc('2026-03-09T15:45:00Z').tz('America/Chicago').format('h:mm A');
// "9:45 AM"
// ── TASK: Humanize time difference ────────────────────────────────────
// date-fns v4
import { formatDistance } from 'date-fns';
formatDistance(new Date('2026-03-01'), new Date(), { addSuffix: true });
// "8 days ago"
// Temporal API (no built-in humanize — use Intl.RelativeTimeFormat)
const diff = Temporal.Now.plainDateISO().since('2026-03-01', { largestUnit: 'days' });
const rtf = new Intl.RelativeTimeFormat('en', { style: 'long' });
rtf.format(-diff.days, 'day'); // "8 days ago"
// Day.js + relativeTime plugin
dayjs('2026-03-01').fromNow(); // "8 days ago"
Bundle Size Comparison
| Library | Bundle Size (minified + gzip) | Notes |
|---|---|---|
dayjs core | ~2KB | Plugins add 1–3KB each |
date-fns (5 functions) | ~4KB | Tree-shaken |
date-fns (20 functions) | ~8KB | Typical app usage |
date-fns full import | ~13KB | import * from 'date-fns' |
date-fns-tz | +3KB | Timezone support add-on |
@js-temporal/polyfill | ~60KB | Will be 0KB when native |
luxon | 24KB | Not worth it vs date-fns-tz |
moment | 70KB | Never use for new projects |
Package Health
| Package | Weekly Downloads | Trend |
|---|---|---|
date-fns | ~30M | Stable |
dayjs | ~18M | Stable |
@js-temporal/polyfill | ~500K | Growing |
luxon | ~8M | Declining |
moment | ~12M | Declining (legacy) |
When to Choose Each
Choose date-fns v4 when:
- Starting a new TypeScript project — it is the safest default
- Bundle size matters and you want tree-shaking
- You want pure functions and predictable immutable behavior
- You need a large ecosystem of date operations (date-fns has ~200 functions)
- You need solid timezone support via
date-fns-tz
Choose Temporal API (with polyfill) when:
- You are building something new and want to future-proof
- You need DST-correct arithmetic — Temporal handles this better than any library
- You are working with complex calendar systems (non-Gregorian calendars)
- You have complex timezone conversion requirements
- You are willing to accept ~60KB polyfill overhead today in exchange for 0KB cost when browsers ship it natively
Choose Day.js when:
- Migrating from Moment.js — the API is nearly identical, migration is low-effort
- You want a chaining API (
dayjs().add(1, 'week').startOf('month')) - Bundle size is critical and you need only a few operations
- You already have a Day.js-dependent codebase
Avoid for new projects:
- Moment.js — 70KB, mutable, officially in maintenance mode
- Luxon — 24KB, good library but date-fns + date-fns-tz covers its use case at lower cost