JavaScript Date Libraries Compared: Luxon vs date-fns vs Day.js vs Temporal—When to Use Each
Compare Luxon, date-fns, Day.js, and Temporal. Bundle size, time zone support, API design, and which to pick for your JavaScript project in 2026.
JavaScript dates are broken. The native Date object doesn't handle time zones, parsing is unreliable, and math is painful. JavaScript date library comparison tools like Luxon, date-fns, Day.js, and the emerging Temporal standard each solve different parts of the problem—but none solve all of them. This comparison shows the trade-offs.
Introduction
If you're still using Moment.js in 2026, the maintainers themselves tell you to stop. The library is in legacy mode. Moment doesn't tree-shake well, it's a memory hog, and it encourages chainable mutation patterns that make testing and reasoning about code harder. But Moment's death left a vacuum: what do you reach for instead?
The answer isn't "pick one library for everything." Each of the four main contenders—Luxon, date-fns, Day.js, and the JavaScript Temporal proposal—wins in different scenarios. A distributed team coordinating across continents has different needs than a frontend developer building a calendar widget. A backend service parsing millions of ISO 8601 date format for developers timestamps has different constraints than a mobile app worried about bundle size.
This article cuts through marketing-speak and compares on three dimensions: what each library does well, where it fails hard, and the real bundle-size and parsing-accuracy trade-offs. You'll see examples of where all four libraries mishandle ambiguous times during daylight-saving transitions, and a decision matrix to pick one for your actual use case.
The Moment.js Crisis and Why Alternatives Matter
Moment.js was brilliant in 2011. A single, chainable API for date math made working with JavaScript dates feel less awful. Then the web changed. Tree-shaking became standard. Bundle budgets tightened. Moment got bigger—not because it added features, but because it was monolithic: you couldn't import just the time-zone parser; you got the whole library.
In 2020, the Moment maintainers posted a now-famous status message: Moment.js is in legacy mode. Don't use it in new projects. The official recommendation is to use Luxon, date-fns, Day.js, or native Intl APIs instead.
The core problem Moment never solved: immutability. Moment objects mutate by default. Call .add(1, 'day') and you modify the original. This breaks React, Vue, and any reactive system expecting pure functions. You have to remember to clone: moment().clone().add(1, 'day'). Moment also conflates parsing logic with time-zone handling in ways that make edge cases (like times that don't exist during DST springs) confusing to debug.
The alternatives each learned from Moment's mistakes. But they solved different problems, which is why comparing bundle size alone is misleading.
Luxon: Interval, Duration, and Time Zone Power (and the Bundle Cost)
Luxon is the spiritual successor to Moment, built with immutability and time zones as first-class citizens from the start. It's maintained by the same author who built Moment. If you're doing serious datetime math—computing business hours, handling recurring events, working with intervals—Luxon is closest to "a complete solution."
Core strengths:
- Intervals and durations are built-in objects, not ad-hoc calculations.
Interval.fromDateTimes(start, end).length('hours')is legible and correct. - Immutable by design. Every operation returns a new object. No cloning required.
- Zone-aware formatting.
DateTime.local().setZone('America/New_York').toFormat('yyyy-MM-dd HH:mm ZZZZZ')includes offset info without extra work. - DST-aware arithmetic. Add 1 day to a date near DST? Luxon's
plus({ days: 1 })handles the transition correctly.
The catch: Luxon is the heaviest of the four. Minified and gzipped, the core is ~14 KB. Add zone support (via IANA IANA time zone database and tzdata), and you're closer to 40+ KB if you ship the full data. For a backend Node service, this is a non-issue. For a client-side single-page app on a 3G connection, you need to decide if interval math is worth the payload.
Example: computing available time slots across time zones.
import { DateTime, Interval } from 'luxon';
const workday = Interval.fromDateTimes(
DateTime.now().setZone('America/New_York').set({ hour: 9, minute: 0 }),
DateTime.now().setZone('America/New_York').set({ hour: 17, minute: 0 })
);
console.log(workday.length('hours')); // 8
Gotcha: Luxon parses RFC 2822 by default, not ISO 8601 date format for developers. If you're storing dates as ISO strings in your database, you need .fromISO() explicitly, not .fromString(). Get this wrong and Luxon will assume the browser's local time zone, causing bugs that only surface in other regions.
date-fns: Pure Functions and Tree-Shaking—Trade-Offs Explained
date-fns takes the opposite philosophical approach from Luxon: instead of an object-oriented API with chainable methods, it's a library of pure functions. Each operation is a separate import: import { addDays } from 'date-fns'. No monolithic bundle.
This is both a strength and a constraint.
Strengths:
- Tiny by default. Core is ~4 KB gzipped, and you only pay for what you use. Build tools tree-shake automatically.
- Pure functions are testable. No hidden state, no mutation surprises.
- Predictable performance. Each function does one thing; you're not loading features you don't need.
- Good format and parse support.
format(date, 'yyyy-MM-dd HH:mm')works as expected, and the formatter is well-tested.
Constraints:
- Time zone support is bolted-on. date-fns has no native time-zone-aware math. To work with time zones, you import a separate module:
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns/tz'. This adds ~5 KB, and the API is less fluent. - No interval/duration objects. Computing "hours between two dates" requires manual math:
(endDate - startDate) / (1000 * 60 * 60). It's simple but error-prone at scale. - Parsing is stricter. By default, date-fns won't parse dates that don't match the format exactly. This is safer (it catches bugs), but it's less forgiving than Luxon.
Example: working with time zones in date-fns.
import { utcToZonedTime, format } from 'date-fns-tz';
const date = new Date('2025-06-15T14:00:00Z');
const nyTime = utcToZonedTime(date, 'America/New_York');
console.log(format(nyTime, 'yyyy-MM-dd HH:mm zzz', { timeZone: 'America/New_York' }));
// 2025-06-15 10:00 EDT
Notice the boilerplate: you convert UTC to zoned, then format with the time zone specified again. This isn't wrong—it's just less elegant than Luxon's fluent API. For time zone math for distributed teams at scale, the repetition adds friction.
Gotcha: date-fns-tz relies on the browser's Intl API. On older devices or in constrained environments, it may fall back to UTC. Always test your time zone code in the environments where it actually runs.
Day.js: The Lightweight Option (and Where It Falls Short)
Day.js is marketed as "2KB alternative to Moment.js." It's API-compatible with Moment's chainable style, but much smaller. For simple use cases—"show today's date," "format a timestamp for display"—Day.js is genuinely sufficient.
Strengths:
- Tiny footprint. Core is ~2 KB gzipped. Formatting plugin adds minimal weight.
- Moment-compatible API. If you've used Moment, Day.js feels familiar:
.add(1, 'day').format('YYYY-MM-DD'). - Plugins for customization. UTC, timezone, and locale plugins are optional; you only load what you need.
Limitations:
- Time zone support is shallow. The timezone plugin exists, but it's less mature and less thoroughly tested than Luxon or date-fns. Ambiguous times during DST transitions are handled, but with less predictability.
- No interval/duration objects. Like date-fns, you're computing time differences manually.
- Parsing is simple. Day.js is very lenient with input, which is convenient but masks bugs. A typo in a date string might parse as "January 1, 2001" instead of throwing an error.
- Mutable-feeling despite immutability claims. Day.js objects are technically immutable, but the chainable API can make you forget to assign the result.
date.add(1, 'day')doesn't modifydate, but beginners frequently assume it does.
Example: a simple Day.js use case.
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
const now = dayjs().tz('America/New_York');
console.log(now.format('YYYY-MM-DD HH:mm'));
This works fine for display. But if you're doing math across time zones—checking if two events overlap, computing meeting availability—Day.js starts to feel too light. You're back to manual calculations.
Day.js's real niche: projects where the bundle matters more than feature richness. A mobile-optimized site showing timestamps? Day.js. A complex scheduling app? Luxon.
Temporal: The Future Native API (When You Can't Use It Yet)
Temporal is a JavaScript language proposal currently at Stage 3 in the TC39 process (as of early 2025). It aims to fix all of JavaScript's date and time problems at the language level. Unlike Date, Temporal has immutability, time zones, and interval math built into the spec itself.
What makes Temporal significant:
- Native performance. No library overhead; date math runs at VM speed.
- Designed from scratch with time zones.
Temporal.PlainDateTimeis time-zone-unaware;Temporal.ZonedDateTimeis time-zone-aware. No ambiguity. - Proper interval support.
Temporal.ZonedDateTime.prototype.until()computes the duration between two times with DST and leap-second handling built-in. - Immutable throughout. Every operation returns a new object; mutation is impossible.
The problem: Temporal is not yet available natively in any browser or Node.js version. There's a polyfill, but it's slow and large (~100 KB minified). Using Temporal today means bundling that polyfill, which defeats the purpose of waiting for native support.
Timeline: Temporal is expected to reach Stage 4 (final approval) by late 2025 or early 2026. Firefox and Safari have signaled support. Chrome's commitment is less clear. Expect native Temporal in browsers around 2026–2027, though adoption will be gradual.
Example: Temporal's clean API.
const now = Temporal.Now.zonedDateTimeISO('America/New_York');
const later = now.add({ hours: 3 });
console.log(later.toString()); // handles DST automatically
This is what we should be able to do natively. For now, if you use Temporal, you're using a polyfill, which is only worth it if your bundle budget allows and you want forward compatibility.
When to start using Temporal: if you're building a library or framework that you'll maintain for years and you want future-proof code. When bundling the polyfill is acceptable. For production web apps today, Temporal is not yet the answer.
Time Zone Support: Which Library Handles Edge Cases?
All four libraries claim time zone support, but they differ dramatically in edge cases. The hardest edge case: times that don't exist (spring forward during DST) or times that exist twice (fall back).
On March 12, 2025, at 2:00 AM, clocks in the Eastern US spring forward to 3:00 AM. The hour from 2:00–3:00 doesn't exist. If you ask "what time is 2:30 AM ET on March 12, 2025?", all libraries struggle.
Luxon: By default, it skips invalid times forward. If you create a DateTime for a non-existent time, it adjusts to the next valid moment (3:00 AM). You can configure this behavior with keepLocalTime option.
const dt = DateTime.fromObject(
{ year: 2025, month: 3, day: 12, hour: 2, minute: 30 },
{ zone: 'America/New_York' }
);
console.log(dt.toString()); // 2025-03-12T03:30:00.000-04:00 (adjusted forward)
date-fns: Doesn't have built-in handling for non-existent times. When you convert a zoned time to UTC, you're responsible for knowing whether that local time actually exists. Get this wrong, and your time is silently off by an hour.
Day.js: Similar to date-fns—it relies on the browser's Intl API, which handles the conversion, but the behavior is undocumented and platform-dependent.
Temporal: Specifies exactly how to handle these cases, with options: disambiguation: 'prefer-later' or 'prefer-earlier' for fall-back ambiguity.
Gotcha everyone makes: If you're parsing user input (a form field with "2:30 AM on March 12"), you cannot reliably determine the offset without asking the user which 2:30 they meant. All libraries will pick one offset and potentially get it wrong. This isn't a library bug; it's a fundamental problem that requires UI design to solve.
For IANA time zone database and tzdata support in production, Luxon is the safest choice. It's the most explicit and the most thoroughly tested in real-world DST scenarios.
Bundle Size Showdown: Gzipped and Parsed
Real numbers, measured with npm ls and esbuild on March 2025 versions:
| Library | Core (gzipped) | With Time Zones | Tree-shaking? |
|---|---|---|---|
| Day.js | 2.0 KB | 3.5–4.5 KB | ✓ good |
| date-fns | 4.0 KB | 9.5 KB (with tz) | ✓ excellent |
| Luxon | 14 KB | 40+ KB (with zones) | ✗ poor |
| Temporal (polyfill) | — | 100+ KB | ✗ poor |
These are minified and gzipped. Parsed size (what actually runs in memory) is 2–3× larger.
What this means in practice:
- Day.js is genuinely small for simple use cases. If you're only parsing and formatting, Day.js has no competitors.
- date-fns with tree-shaking wins for complex apps. Even adding the timezone module, you stay under 10 KB. You only pay for the functions you call.
- Luxon is heavy upfront, but justified if you're doing interval/duration math. The initial load is larger, but you're not writing fallback code for missing features.
- Temporal is a non-starter today unless you're okay with a 100+ KB polyfill. That's a multi-second delay on slow networks.
If your bundle is already 100+ KB, Luxon's 14 KB overhead is a 14% tax. If your bundle is 40 KB, it's a 35% overhead. Know your constraints.
API Philosophy: Immutability, Chaining, and Gotchas
Each library chose a different API style, and this affects how you write code and how easy it is to debug.
Luxon: Fluent, Immutable
const meeting = DateTime.now()
.setZone('America/Chicago')
.plus({ hours: 2 })
.set({ minute: 0 });
Every method returns a new object. Chaining is clean and functional. Immutability is guaranteed. Downside: the mental model is "compute everything inline," which can make conditional logic awkward.
date-fns: Functional, Explicit
const meeting = setMinutes(addHours(utcToZonedTime(new Date(), 'America/Chicago'), 2), 0);
Or spread across lines:
let meeting = utcToZonedTime(new Date(), 'America/Chicago');
meeting = addHours(meeting, 2);
meeting = setMinutes(meeting, 0);
Immutability is implicit (functions return new values), but the call stack can get deep. Advantage: dead-simple to reason about—no chaining magic, no method lookup surprises.
Day.js: Moment-like, Chainable
const meeting = dayjs().add(2, 'hours').set('minute', 0);
Feels like Moment. Easy to migrate from Moment codebases. Gotcha: beginners forget to assign the result or don't realize they need to clone if they want to keep the original.
Temporal (future): Explicit, Immutable
const meeting = Temporal.Now.zonedDateTimeISO('America/Chicago')
.add({ hours: 2 })
.with({ minute: 0 });
Most explicit about what's time-zone-aware and what's not. .with() returns a new object; no mutation.
For distributed teams, immutability matters. If you're passing dates between services or storing them in state, Luxon or Temporal is safer. For small scripts, Day.js's chainable syntax is faster to write.
Date Parsing and Formatting: Where Each Fails
Parsing is where date libraries cause the most production bugs. Every library has gotchas.
Luxon's parsing gotcha: Defaults to RFC 2822, not ISO 8601.
DateTime.fromString('2025-06-15T14:00:00Z'); // Expects RFC 2822, fails or misparses
DateTime.fromISO('2025-06-15T14:00:00Z'); // Correct for ISO
If you're parsing database timestamps (which should always be ISO 8601), use .fromISO() explicitly. Many bugs happen because devs use .fromString() for ISO input.
date-fns's parsing gotcha: Format string must match exactly.
parse('06/15/2025', 'MM/dd/yyyy', new Date()); // OK
parse('6/15/2025', 'MM/dd/yyyy', new Date()); // Throws error or misparses
If your input has leading zeros stripped, you need a separate format. This is safer (you catch malformed input), but it's more rigid.
Day.js's parsing gotcha: Too lenient.
dayjs('2025-13-50'); // Parses as some "valid" date, doesn't error
Day.js tries to be helpful and auto-correct. For user input from a form, this is dangerous—a typo silently becomes January 1.
Formatting gotchas, all libraries:
YYYYis often ambiguous (do you mean year or week-year?). Useyyyyin ISO-style libraries.- Timezone offset format (
Zvszvszzz) differs across libraries. Check docs. - When storing dates in databases, always use ISO 8601 with explicit
Zsuffix (UTC). Let the database and library handle conversions, don't try to be clever.
Rule of thumb: parse once at the boundary (input), store as Unix timestamps and epoch time or ISO UTC, and format only for display. Every library supports this pattern; stray from it and you invite bugs.
Migration Path: From Moment.js or Native Date to What?
If you're maintaining legacy Moment.js code, the migration path depends on your app size and time zone complexity.
Small app, simple dates: Migrate to Day.js. It's API-compatible with Moment, so the changes are mostly imports. Then gradually simplify: dayjs().add(1, 'day').format() becomes fewer chains as you refactor.
Large app, complex date math: Migrate to Luxon. The API is different, but the mental model is similar. Interval and duration objects are powerful. Expect 1–2 weeks of refactoring for a medium codebase. Example:
// Moment
const days = moment(end).diff(moment(start), 'days');
// Luxon
const days = Interval.fromDateTimes(start, end).length('days');
Bundle-conscious app: Migrate to date-fns. This is the most work (pure functions are a different paradigm), but you get the best tree-shaking. It's worth it if your bundle budget is tight. Test your production bundle size before and after to prove ROI.
Future-proof: Use the Temporal polyfill today if you want to write forward-compatible code and you're okay with the payload. Your code will work unchanged when native Temporal lands. This is most useful for library authors.
Real-World Example: Building a Cross-Timezone Scheduler
Imagine you're building a scheduling tool for a distributed team. Team members are in New York, London, and Tokyo. You need to:
- Let each user define their working hours in their local time zone.
- Find overlapping hours across all zones.
- Display suggested meeting times in each person's local zone.
With Luxon:
import { DateTime, Interval } from 'luxon';
const ny = Interval.fromDateTimes(
DateTime.now().setZone('America/New_York').set({ hour: 9, minute: 0 }),
DateTime.now().setZone('America/New_York').set({ hour: 17, minute: 0 })
);
const london = Interval.fromDateTimes(
DateTime.now().setZone('Europe/London').set({ hour: 9, minute: 0 }),
DateTime.now().setZone('Europe/London').set({ hour: 17, minute: 0 })
);
const tokyo = Interval.fromDateTimes(
DateTime.now().setZone('Asia/Tokyo').set({ hour: 9, minute: 0 }),
DateTime.now().setZone('Asia/Tokyo').set({ hour: 17, minute: 0 })
);
// Find intersection (overlapping hours)
const overlap = ny.intersection(london)?.intersection(tokyo);
if (overlap) {
console.log(`Meeting slot: ${overlap.start.toISO()} to ${overlap.end.toISO()}`);
}
With date-fns, you'd need to:
- Convert each local working-hours interval to UTC.
- Compute intersections manually.
- Convert back to each zone for display.
It's possible but verbose. This is where Luxon shines.
With Day.js: You'd write custom logic to compute overlaps. Doable, but error-prone.
Decision Matrix: Pick Your Library by Use Case
Use this to cut through the noise:
| Use Case | Pick | Why |
|---|---|---|
| Simple timestamps, display only | Day.js | 2 KB, fast to learn, Moment-compatible. |
| Bundle-sensitive SPA, moderate date use | date-fns | 4–10 KB with tree-shaking, pure functions, easy to test. |
| Interval/duration math, recurring events, scheduling | Luxon | Interval/Duration objects, fluent API, bulletproof DST handling. |
| Complex time zone edge cases, backend services | Luxon | Best zone support, explicit disambiguation options. |
| Backward-compatible with Moment, mid-size app | Day.js | Smallest breaking changes, good-enough feature set. |
| Future-forward, library code, can tolerate polyfill | Temporal | Future-proof, native eventually, spec-aligned behavior. |
| No date library (just native JavaScript) | ❌ Don't. | Native Date is genuinely broken. Always use a library. |
Frequently Asked Questions
What replaced Moment.js in 2025?
There's no single successor. Luxon, date-fns, Day.js, and Temporal each captured different segments of Moment's user base. Luxon is the closest spiritual successor (same author, interval support). date-fns dominates in bundle-sensitive projects. Day.js is the easiest migration for existing Moment codebases. Choose based on your needs, not habit.
Does date-fns handle time zones well?
date-fns has solid time zone conversion support via date-fns-tz, but it's functional rather than fluent. You're converting to and from UTC, not working in zones directly. For simple "show this time in another zone" tasks, it works. For complex interval math across zones, Luxon is stronger.
Is Temporal production-ready?
Not yet. The polyfill is slow and large (~100 KB). Use Temporal only if you're building libraries or long-term codebases where bundle cost is offset by future benefits. For production web apps in 2025, it's early.
Can I mix libraries (use Luxon for math, date-fns for formatting)?
Technically yes, but don't. Each library has its own internal representation, and converting between them is error-prone. Pick one and commit. The conversion overhead often negates any bundle savings from mixing.
Why do all libraries struggle with ambiguous DST times?
Because the problem is unsolvable at the library level. If a user enters "2:30 AM on the day clocks spring forward," there is no valid answer without asking "which 2:30 did you mean?" The library can provide options, but the UI must ask the question.
Should I store dates as UTC in my database?
Yes, always. Store Unix timestamps and epoch time (seconds since January 1, 1970) or ISO 8601 with explicit Z suffix (UTC). This is independent of which library you use. The library handles conversion to and from user-facing time zones; the database stays UTC-only. This is the single most important rule to prevent bugs.
Bottom Line
No date library is universally best. Moment.js's failure wasn't about feature gaps—it was about architecture: monolithic, mutable, and too heavy for modern web apps. Its successors learned different lessons. Luxon prioritizes correctness and fluent APIs at the cost of bundle size. date-fns optimizes for bundle size and functional purity. Day.js chases minimalism. Temporal is the future native standard, waiting for browser support. For most teams in 2025, Luxon (if time zone or interval math is critical) or date-fns (if bundle matters) is the answer. Day.js is viable for simple cases. Temporal is a bet on 2026+. Measure your actual constraints—bundle size, parsing correctness, DST edge cases—and choose accordingly. Don't default to "what I've always used."
We build practical, free time and date tools at epochcalc.com — every calculation runs in your browser using IANA tzdb via Luxon, so DST and zone math are correct by construction.