Skip to main content

date-fns v4 vs Temporal API vs Day.js for JavaScript Date Handling (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

Browser Support and Polyfilling Temporal

The Temporal API reached TC39 Stage 3 in 2021, meaning the specification is stable and browser implementations began. As of 2026, native Temporal support ships in Firefox and Safari Technology Preview, with Chrome implementation in progress. The @js-temporal/polyfill package from the TC39 champions brings full Temporal support to any JavaScript environment today.

The polyfill is approximately 60KB minified and gzipped — a substantial cost for a browser bundle. Whether this is acceptable depends on your use case. Applications with complex timezone requirements, calendar systems, or financial date arithmetic gain enough correctness guarantees from Temporal to justify the size. Applications doing simple date formatting or relative time display do not — date-fns with a handful of tree-shaken functions covers those cases at a fraction of the size.

The polyfill performance is within a reasonable range for most use cases. Temporal operations through the polyfill run in pure JavaScript without native browser acceleration, which makes them measurably slower than native Date operations. For typical application code — formatting timestamps, computing durations, scheduling logic — this is imperceptible. For code that processes thousands of dates in a tight loop (data analytics, calendar rendering), the polyfill overhead becomes measurable. Benchmark your specific use case before committing.

The safe strategy for adopting Temporal today is to use it behind a feature detection shim that falls back to date-fns for environments without polyfill support. This is straightforward: if typeof Temporal !== 'undefined', use Temporal; otherwise use date-fns. As native implementations roll out across browsers, the polyfill shims out and your bundle shrinks automatically. The application code never changes. This is the transition strategy the TC39 champions recommend for production adoption.

One practical consideration: the @js-temporal/polyfill package must be imported before any code that uses Temporal. In a Next.js application, import it in your root layout component or in _app.tsx. In a Node.js service, import it at the top of your entrypoint before other modules. Because the Temporal spec is finalized, the polyfill's API will not change — code written against @js-temporal/polyfill today will work identically against native Temporal when browsers ship it.


Migration Path from Moment.js

Moment.js's deprecation announcement in 2020 triggered a migration wave that is still ongoing. Many codebases are in a partially-migrated state where new code avoids Moment but existing code still imports it. The two most common migration targets — date-fns and Day.js — have different friction profiles.

Migrating from Moment.js to Day.js is the lower-friction path, by design. Day.js was explicitly built with a Moment-compatible API. The primary data type, dayjs(), works like moment(). Methods like .add(), .subtract(), .format(), .isBefore(), .isSame(), .startOf(), and .endOf() work with the same signatures. For a large codebase with many Moment usages, a mechanical find-and-replace of moment( with dayjs( and import moment from 'moment' with import dayjs from 'dayjs' passes for the majority of call sites. You then handle the exceptions: anything that uses Moment-specific APIs like .fromNow() requires the relativeTime plugin, timezone operations require the timezone and utc plugins, and so on.

What actually breaks in a Moment → Day.js migration: Moment's moment.duration() API, which Day.js replaces with the duration plugin (different method names). Moment's .calendar() method has no direct Day.js equivalent. Locale-specific formatting differences exist between Moment and Day.js for the same format tokens. Moment's LT, LTS, L, LL, LLL, LLLL locale-aware format tokens are not supported in Day.js by default. For most applications, these are minor and easily fixed.

Migrating from Moment.js to date-fns requires more cognitive work but produces code that is architecturally cleaner. date-fns uses pure functions rather than a fluent object API, so moment(date).add(1, 'day').format('YYYY-MM-DD') becomes format(addDays(date, 1), 'yyyy-MM-dd'). The format token syntax differs: Moment uses YYYY for 4-digit year, date-fns uses yyyy. Moment uses MM for month and DD for day; date-fns uses the same tokens but the case matters — dd vs DD produce different results in date-fns. This token incompatibility is the most common source of bugs in Moment → date-fns migrations.

The practical advice: if your Moment codebase is large and you need migration to be low-risk, migrate to Day.js first. The surface area of changes is smaller, the test suite will catch fewer regressions, and the API similarity means reviewers can verify correctness without learning a new mental model. Then, if you later want date-fns's tree-shaking and pure-function model, the Day.js → date-fns migration is a smaller, more tractable second step.


Locale and Internationalization

Formatting dates for a global audience requires more than timezone conversion. The order of day, month, and year varies by locale. The names of months and weekdays differ. Week numbering conventions differ — ISO 8601 starts weeks on Monday, the US convention starts on Sunday. All three libraries handle localization, but the depth and ease of support differs meaningfully.

date-fns ships locale objects as separate imports. Using the French locale for month names requires importing the locale object and passing it to format:

import { format } from 'date-fns';
import { fr } from 'date-fns/locale';

format(new Date(), 'PPPP', { locale: fr });
// "jeudi 9 mars 2026"

format(new Date(), 'MMMM', { locale: fr });
// "mars"

date-fns ships locale data for over 70 languages, and because each locale is a separate import, only the locales your application uses are included in the bundle. This is the tree-shaking advantage applied to i18n — a global product that needs only English and Japanese does not pay for the 68 other locale files.

Day.js handles locales through a plugin system as well. You import the locale file and call dayjs.locale() to set it globally or pass it per-call:

import dayjs from 'dayjs';
import 'dayjs/locale/ja';

dayjs.locale('ja');
dayjs().format('MMMM D日 YYYY年');
// "3月 9日 2026年"

// Per-call locale (does not change global setting)
dayjs().locale('fr').format('MMMM');
// "mars"

The Temporal API delegates locale formatting entirely to Intl.DateTimeFormat, which the browser and Node.js provide natively. No locale data ships with the Temporal polyfill — every locale the runtime supports is automatically available:

const date = Temporal.Now.plainDateISO();

date.toLocaleString('ja-JP', { year: 'numeric', month: 'long', day: 'numeric' });
// "2026年3月9日"

date.toLocaleString('ar-SA', { dateStyle: 'full' });
// "الاثنين، 9 مارس 2026"

date.toLocaleString('de-DE', { dateStyle: 'medium' });
// "9. März 2026"

The Temporal approach to i18n is architecturally the cleanest — locale data comes from the platform rather than a library dependency, so it is always current and never adds to your bundle size. The practical advantage of date-fns's approach is that format strings remain familiar (MMMM d, yyyy) while allowing per-locale name localization. Temporal's toLocaleString options follow the Intl.DateTimeFormat option syntax, which is more verbose but also more precise about which calendar system and numbering system to use.


Testing Date-Dependent Code

Code that depends on the current date is notoriously difficult to test reliably. Tests that pass today may fail tomorrow if they hardcode an assertion about "now." The three libraries handle testability differently.

date-fns functions accept a Date object as input, which makes them straightforward to test with fixed dates — just pass a known new Date('2026-03-09T12:00:00Z') instead of new Date(). The challenge is code that calls new Date() internally. The standard solution is to abstract the "current time" source so tests can substitute a fixed value:

// Instead of calling new Date() directly in business logic
function getUpcomingDeadlines(tasks: Task[]): Task[] {
  const now = new Date(); // hard to test
  return tasks.filter(t => isAfter(t.deadline, now));
}

// Accept the current time as a parameter — testable
function getUpcomingDeadlines(tasks: Task[], now = new Date()): Task[] {
  return tasks.filter(t => isAfter(t.deadline, now));
}

// In tests: pass a fixed date
const testNow = new Date('2026-03-09T00:00:00Z');
const results = getUpcomingDeadlines(tasks, testNow);

This pattern — passing "now" as a parameter with a default of new Date() — is idiomatic for date-fns-based code. It requires no mocking framework and no special test setup.

Day.js's global dayjs() call is slightly harder to control in tests. The dayjs function itself reads system time. The common approach is to use vitest's vi.setSystemTime() or Jest's jest.useFakeTimers() to freeze the system clock for the duration of a test:

import { vi, describe, it, beforeEach, afterEach } from 'vitest';

describe('deadline calculation', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-09T12:00:00Z'));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('returns tasks due after now', () => {
    const tasks = [{ deadline: '2026-03-10' }, { deadline: '2026-03-08' }];
    const upcoming = getUpcomingDeadlines(tasks);
    expect(upcoming).toHaveLength(1);
  });
});

The Temporal polyfill respects fake timers in both Vitest and Jest when Temporal.Now is used — vi.setSystemTime() affects Temporal.Now.instant() and Temporal.Now.zonedDateTimeISO(). This makes Temporal-based code as testable as Day.js, without requiring the parameter-passing pattern. For new codebases using Temporal, the convenience of Temporal.Now with fake timer support is a meaningful testing ergonomics advantage.


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.