date-fns vs Day.js vs Luxon 2026: Which Date Library Wins?
TL;DR
date-fns for functional, tree-shakeable date utilities. Day.js as a lightweight Moment.js drop-in. Luxon for timezone-heavy applications. Moment.js is dead — stopped adding features in 2020 and recommends migrating away. The right choice in 2026 depends on your bundle size requirements and timezone needs.
Key Takeaways
- date-fns: ~40M weekly downloads — most popular post-Moment choice
- Day.js: ~25M downloads — smallest bundle, Moment.js compatible API
- Luxon: ~12M downloads — best timezone handling, Intl-based
- Moment.js: declining — in legacy maintenance mode
- Temporal API coming — browser-native, but not widely available yet
Why Moment.js Died (And Why It Matters)
Understanding why the JavaScript date library landscape looks the way it does in 2026 requires understanding the Moment.js collapse. Moment wasn't just popular — it was dominant, with 20M+ weekly downloads at its peak. The Moment team deprecated it in September 2020 with an official recommendation to migrate to alternatives.
The reasons were specific and instructive. Moment objects are mutable — calling .add() or .subtract() modifies the original object instead of returning a new one, which is a footgun in functional codebases. The bundle size was 72KB gzipped, unmovable because the entire library loaded regardless of which functions you used. And Moment's timezone support required a separate 100KB+ moment-timezone package with a bundled IANA timezone database.
The successors each solved a different subset of these problems. Day.js addressed bundle size. date-fns addressed mutability and tree-shaking. Luxon addressed timezone handling properly. None of them are drop-in replacements for each other — they made different architectural choices that reflect different priorities.
The Options
date-fns — Functional & Tree-Shakeable
import { format, addDays, differenceInDays, parseISO, isWithinInterval } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
// Only imports what you use — ideal for bundling
const now = new Date();
format(now, 'yyyy-MM-dd'); // '2026-03-08'
format(now, 'MMMM d, yyyy'); // 'March 8, 2026'
addDays(now, 7); // Date 7 days from now
differenceInDays(new Date('2026-04-01'), now); // Days until April 1
// Parse ISO strings
const date = parseISO('2026-03-08T10:30:00');
// Timezone formatting (date-fns-tz)
formatInTimeZone(now, 'America/New_York', 'yyyy-MM-dd HH:mm zzz');
// '2026-03-08 05:30 EST'
Bundle: ~13KB for common operations (tree-shakeable) Best for: Functional style, TypeScript projects, webpack/Vite bundling
date-fns takes a different architectural approach from Moment.js, Day.js, and Luxon: instead of a fluent OOP API (moment().add(7, 'days').format(...)), it's a collection of pure functions that accept and return native Date objects. This has two significant consequences. First, perfect tree-shaking: bundle analysis tools report that the average date-fns user imports about 8KB of actual functions, well below the theoretical maximum. Second, the functions are composable in ways that OOP chains aren't — you can pipe them, partially apply them, and reason about them as pure transformations.
The TypeScript story is excellent. date-fns ships its own types (no @types/date-fns needed), and the function signatures are accurate enough that TypeScript catches common errors like passing a string where a Date is expected.
The one limitation is timezone support. The separate date-fns-tz package handles timezone formatting but doesn't provide a timezone-aware Date type — it's conversion utilities rather than first-class timezone objects. For applications where business logic involves timezone arithmetic (e.g., "find all meetings in this week for a user in New York"), date-fns-tz is workable but not as ergonomic as Luxon.
Day.js — Smallest Bundle
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
// Moment.js-compatible chainable API
dayjs('2026-03-08').format('MMMM D, YYYY'); // 'March 8, 2026'
dayjs().add(7, 'days').format('YYYY-MM-DD');
dayjs('2020-01-01').from('2026-01-01'); // '6 years ago'
// Timezone
dayjs().tz('America/New_York').format('HH:mm'); // '05:30'
Bundle: ~2KB base + ~3KB per plugin Best for: Moment.js migration, bundle size constraints
Day.js achieved something clever: API compatibility with Moment.js at ~3% of the size. The core API covers format(), add(), subtract(), diff(), and isBefore()/isAfter() — the 90% of Moment.js that most applications actually use. Most Moment.js migrations to Day.js involve replacing import moment from 'moment' with import dayjs from 'dayjs' and updating a handful of method names.
The plugin system is Day.js's answer to missing functionality. Need relative time ("3 hours ago")? Add the relativeTime plugin. Need timezone support? Add timezone and utc. Each plugin is ~3KB, so a Day.js setup with common plugins typically lands at 10-15KB — still well below Moment.js.
The caveat is that Day.js objects are technically immutable but use a slightly different mechanism than date-fns. Methods return new Day.js instances rather than modifying the original. This is correct behavior, but developers used to Moment.js's mutation bugs can still introduce issues if they're not careful about capturing return values.
Day.js's 25M weekly downloads reflect Moment.js migration velocity more than organic new adoption. It's the pragmatic choice for teams that built on Moment.js and want the least painful upgrade path.
Luxon — Best Timezone Support
import { DateTime, Duration, Interval } from 'luxon';
// Rich timezone support via Intl API
const dt = DateTime.now().setZone('America/New_York');
dt.toFormat('yyyy-MM-dd HH:mm ZZZZ'); // '2026-03-08 05:30 Eastern Standard Time'
dt.zoneName; // 'America/New_York'
dt.offset; // -300
// Duration math
const dur = Duration.fromObject({ months: 1, days: 3 });
DateTime.now().plus(dur).toISO();
// Intervals
const interval = Interval.fromDateTimes(
DateTime.local(2026, 1, 1),
DateTime.local(2026, 12, 31)
);
interval.count('days'); // 364
Bundle: ~23KB gzipped Best for: Complex timezone handling, international applications
Luxon was built by Isaac Cambron, a Moment.js contributor, specifically to fix the timezone problem. Rather than shipping its own timezone database (like Moment Timezone's 100KB+), Luxon uses the browser's built-in Intl API. This means Luxon doesn't need to ship timezone data — it delegates to the runtime, which already knows about DST transitions, historical timezone rules, and locale-specific formatting.
The practical consequence is that Luxon handles timezone arithmetic correctly in ways that date-fns-tz and Day.js's timezone plugin can struggle with. Converting a meeting time from UTC to a user's local timezone while accounting for DST is handled correctly by Luxon's DateTime.setZone(). Creating a recurring event rule that "fires at 9am New York time" and asking when the next occurrence is — including across DST transitions — works with Luxon's Interval and Duration objects.
The Interval and Duration types are unique to Luxon among the main alternatives. Interval.fromDateTimes() represents a span of time as a first-class object, enabling operations like splitting an interval into days, checking if two intervals overlap, and humanizing durations. These capabilities are particularly useful for calendar applications and scheduling tools.
At 23KB, Luxon is heavier than Day.js but lighter than Moment.js. For applications where timezone handling is a core concern, the size tradeoff is worth it.
Comparison Table
| Feature | date-fns | Day.js | Luxon | Moment.js |
|---|---|---|---|---|
| Downloads (wk) | 40M | 25M | 12M | 15M (legacy) |
| Bundle size | ~13KB* | ~2KB | ~23KB | ~72KB |
| Immutable | ✓ (functions) | ✓ | ✓ | ✗ |
| Timezone | Plugin (date-fns-tz) | Plugin | Native (Intl) | Plugin |
| TypeScript | ✓ | ✓ | ✓ | @types/moment |
| Tree-shakeable | ✓✓ | ✓ | ✓ | ✗ |
| Maintenance | Active | Active | Active | Maintenance-only |
*date-fns tree-shakes to only imported functions
Migrating from Moment.js
If you have an active Moment.js codebase, the migration choice depends on your usage patterns:
Migrate to Day.js if your codebase uses Moment's fluent chained API extensively and you want the smallest diff. Day.js's API compatibility means most changes are mechanical find-and-replace. Typical effort: 2-4 hours for a medium codebase.
Migrate to date-fns if your codebase already has functional patterns and you use TypeScript. The migration requires more thought — you're switching mental models, not just APIs — but the result is more idiomatic modern JavaScript. Typical effort: 4-8 hours.
Migrate to Luxon if your application has significant timezone logic, especially DST edge cases. The migration is the most work of the three, but Luxon's timezone handling will fix bugs you didn't know you had. Typical effort: 8-16 hours.
Testing with Date Libraries
Date manipulation code is notoriously hard to test because new Date() returns the current time. All three libraries work well with vi.setSystemTime() (Vitest) or Jest's useFakeTimers().
date-fns's pure functions make them especially testable — no global state, no side effects, easily unit-tested in isolation. Luxon's DateTime.fromISO() is straightforward to use in tests. Day.js mock support is standard.
The Temporal API
// Temporal — upcoming browser-native date API
// Proposal stage 3, polyfill available
import { Temporal } from '@js-temporal/polyfill';
const date = Temporal.Now.plainDateISO();
console.log(date.toString()); // '2026-03-08'
const datetime = Temporal.Now.zonedDateTimeISO('America/New_York');
datetime.add({ days: 7 }).toString();
// Better timezone support than any library
// Immutable by design
// Not yet in most browsers — use polyfill for now
Temporal is the future, but not ready for production without a polyfill (~100KB). The proposal reached Stage 3 in 2021 and as of 2026 is available behind flags in some browsers, but full support without polyfills isn't here yet. The polyfill is 100KB — larger than any current library except Moment.js with timezone support.
Temporal fixes everything: immutability by design, proper timezone support via ZonedDateTime, PlainDate for calendar-only dates without timezone complications, and duration arithmetic that handles calendar months correctly. When Temporal ships universally, it will make all three libraries above obsolete for new projects. Until then, use date-fns or Luxon.
Recommendations
| Use Case | Pick |
|---|---|
| New TypeScript project | date-fns |
| Migrating from Moment.js | Day.js (API compatible) |
| Timezone-heavy international app | Luxon |
| Minimum bundle, CDN delivery | Day.js |
| Relative time ("3 hours ago") | Day.js (built-in plugin) or date-fns |
| Calendar/scheduling application | Luxon |
| Future-proof | Wait for Temporal + use polyfill now |
Bundle Impact and SSR Considerations
Date libraries are often underestimated as bundle size contributors. The actual impact depends on your import patterns and bundler configuration, and differs between client and server rendering contexts.
Tree-Shaking in Practice
date-fns's theoretical bundle is ~13KB for "common operations" — but in practice, the typical date-fns import in a React application looks like this:
import { format, parseISO, addDays, differenceInCalendarDays, isAfter } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
Six functions. With tree-shaking, this imports approximately 8-10KB — less than the full library but more than Day.js's 2KB base. The gap narrows when you factor in that Day.js users typically add 3-4 plugins (relative time, timezone, duration), pushing Day.js to 12-18KB in practice. Both end up in a similar range for typical applications.
Date Libraries and SSR
All three libraries work correctly in Node.js SSR environments, but with behavioral differences. The critical concern is timezone handling: a server rendering a date in UTC will display a different formatted string than a browser in New York or Tokyo rendering the same timestamp. This mismatch — correct UTC on the server, localized time in the browser — creates hydration errors in Next.js and similar frameworks.
The standard pattern for timezone-safe SSR is to render dates as UTC strings on the server and convert to local time in a client-side useEffect:
// Hydration-safe date rendering
'use client';
import { format } from 'date-fns';
function PublishedAt({ isoDate }: { isoDate: string }) {
const [formatted, setFormatted] = useState(isoDate.slice(0, 10)); // Server-safe: just the date
useEffect(() => {
// Runs only on client — timezone matches user's local time
setFormatted(format(new Date(isoDate), 'MMMM d, yyyy'));
}, [isoDate]);
return <time dateTime={isoDate}>{formatted}</time>;
}
Luxon's DateTime.fromISO(date, { zone: 'utc' }) pattern makes timezone semantics explicit — you opt in intentionally rather than inheriting the server's environment timezone.
Locale and Internationalization
date-fns ships locale data as separate imports that participate in tree-shaking:
import { format } from 'date-fns';
import { fr } from 'date-fns/locale';
format(new Date(), 'PPPP', { locale: fr }); // 'mardi 8 mars 2026'
Day.js and Luxon also support localization. Luxon delegates to the browser's built-in Intl API, meaning locale support comes for free without shipping locale data. For applications targeting multiple languages, Luxon's Intl-based localization is the most bundle-efficient option since the timezone database and locale data are provided by the runtime.
Testing Date Manipulation Logic
Date manipulation code is notoriously hard to test because new Date() returns a different value on every invocation. For selecting the right test runner setup for time-mocking, all three major runners handle this differently. Vitest's vi.setSystemTime() and Jest's jest.useFakeTimers() both work cleanly with date-fns and Day.js. Luxon requires an additional step — it reads from Date.now() internally, so fake timers work as expected. Bun test's time mocking is compatible with the Temporal polyfill as well.
One practical note on DST in CI: tests that check relative dates ("is this timestamp in the same week as today") can fail in CI environments running in UTC when the developer machine runs in a DST-observing timezone. The fix is simple but easy to forget: always use UTC explicitly in tests that involve timezone-sensitive comparisons, regardless of which date library you use.
Temporal API: The Future of Date Handling
The ECMAScript Temporal proposal has been at Stage 3 (of 4) in the TC39 standardization process since 2021, and browser implementations began shipping in 2025. Temporal represents the most significant change to JavaScript date handling since Date was introduced in 1995, and it directly addresses the design failures that led to the popularity of date utility libraries in the first place.
What's Wrong with Date
Date has three fundamental problems that no library can fully fix:
First, Date uses mutable objects. Calling date.setMonth(3) modifies the date in place rather than returning a new date. This is a common source of bugs where multiple parts of a codebase hold references to the same Date object and one mutation affects them all. date-fns works around this by always returning new objects; Day.js and Luxon use immutable wrappers.
Second, Date has no timezone support beyond the local system timezone and UTC. The getTimezoneOffset() method returns the offset in minutes but doesn't understand named timezones ("America/New_York", "Europe/Berlin"). Libraries like Luxon and the date-fns-tz package add timezone support by bundling the IANA timezone database — adding ~36KB to your bundle. Day.js's timezone plugin takes a similar approach.
Third, Date conflates calendar representation with time instant. A Date object represents a UTC timestamp, but its methods (.getMonth(), .getDate()) return values in the local timezone, leading to classic off-by-one bugs when comparing dates.
What Temporal Solves
Temporal introduces multiple distinct types for different use cases:
Temporal.Instant— a precise point in time, no timezone, always UTCTemporal.PlainDate— a calendar date (year, month, day) with no time or timezoneTemporal.PlainTime— a time of day with no date or timezoneTemporal.ZonedDateTime— a full datetime with timezone, supporting DST transitions correctlyTemporal.Duration— a span of time with explicit calendar arithmetic
All Temporal objects are immutable — operations return new objects, never modify existing ones. Timezone support is built in, using the same IANA database without separate plugins or bundle cost.
Using Temporal Today
A polyfill (@js-temporal/polyfill) provides the full Temporal API in environments without native support:
import { Temporal } from '@js-temporal/polyfill';
// Unambiguous date parsing (no DST confusion)
const meeting = Temporal.ZonedDateTime.from('2026-03-15T14:00:00[America/New_York]');
// Safe date arithmetic (handles month-end correctly)
const nextMonth = meeting.add({ months: 1 });
// Duration calculations
const duration = Temporal.Now.zonedDateTimeISO().until(meeting);
console.log(`Meeting in ${duration.days} days`);
For new projects starting today, using the Temporal polyfill instead of date-fns or Day.js is a reasonable choice if you can accept the polyfill's 34KB footprint. For projects migrating from Moment.js, Temporal's API is closer to Luxon than to Day.js, since Luxon was explicitly designed as a stepping stone to the Temporal API.
Native Temporal support is shipping in Chrome 130+ and Firefox 128+, with Node.js 22+ providing experimental support. Within the 2026-2027 timeframe, the polyfill approach will become unnecessary for most deployment targets.
Compare date library package health on PkgPulse. Related reading: Best JavaScript Testing Frameworks 2026 and Best JavaScript Charting Libraries 2026.
See the live comparison
View date fns vs. dayjs on PkgPulse →