Skip to main content

date-fns v4 vs Temporal API vs Day.js: Date Handling in 2026

·PkgPulse Team
0

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 TemporalDate + 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 defaultparseISO('2026-03-09T') now throws instead of returning an invalid date silently
  • TypeScript improvements — better generic inference, stricter return types
  • FP module restructureddate-fns/fp submodule 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

LibraryBundle Size (minified + gzip)Notes
dayjs core~2KBPlugins add 1–3KB each
date-fns (5 functions)~4KBTree-shaken
date-fns (20 functions)~8KBTypical app usage
date-fns full import~13KBimport * from 'date-fns'
date-fns-tz+3KBTimezone support add-on
@js-temporal/polyfill~60KBWill be 0KB when native
luxon24KBNot worth it vs date-fns-tz
moment70KBNever use for new projects

Package Health

PackageWeekly DownloadsTrend
date-fns~30MStable
dayjs~18MStable
@js-temporal/polyfill~500KGrowing
luxon~8MDeclining
moment~12MDeclining (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

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.