date-fns vs Temporal: Which to Choose
Choose date-fns when you want small, tree-shakable helper functions layered on the native Date and a near-zero learning curve; choose Temporal when you need immutable types, first-class IANA timezones, DST-aware arithmetic, or non-Gregorian calendars and can ship a polyfill today. Part of Migrating From Legacy Date Libraries.
Why the choice is tricky
These two are not the same kind of tool, which is what makes the comparison subtle. date-fns is a function library: every helper takes and returns a native Date, so it inherits Date's strengths (universal support, zero polyfill) and its weaknesses (the object is mutable, months are zero-indexed, and there is no built-in concept of an IANA timezone — you need the separate date-fns-tz package for that). Temporal is a new type system: it replaces Date with immutable values that carry zone and calendar information, fixing the design flaws at the root — but it is newer, so most production targets still need @js-temporal/polyfill (roughly 18–22KB gzipped), and your team has to learn Instant vs ZonedDateTime vs PlainDate.
The trap is judging them on bundle size alone. date-fns can tree-shake down to a few hundred bytes if you import two functions, but if you also pull in date-fns-tz for timezone work, you have rebuilt a chunk of what Temporal gives you natively — without the correctness guarantees that immutability and explicit disambiguation provide.
Side-by-side
| Dimension | date-fns | Temporal |
|---|---|---|
| Underlying value | native Date (mutable) |
immutable Temporal types |
| Bundle cost | per-function, tree-shakable (tiny) | polyfill ~18–22KB gzipped (until native) |
| Timezone support | needs date-fns-tz add-on |
built into ZonedDateTime |
| DST-aware arithmetic | partial, via add-on | first-class, with disambiguation options |
| Non-Gregorian calendars | no | yes (calendar field) |
| Overflow control | implicit (Date clamps) |
explicit 'constrain'/'reject' |
| Learning curve | minimal (just functions) | moderate (new type model) |
| Native availability | everywhere (it's Date) |
shipping; polyfill for older targets |
Minimal solution: the same task in each
Add one calendar month to January 31 — the classic month-end rollover.
// date-fns: operates on a native Date, clamps to Feb 28/29 automatically
import { addMonths } from 'date-fns';
const result = addMonths(new Date(2024, 0, 31), 1); // month is 0-indexed: 0 = January
// result is 2024-02-29 (clamped), but the policy is implicit and unconfigurable
// Temporal: immutable PlainDate, with an explicit overflow policy
import { Temporal } from '@js-temporal/polyfill';
const start = Temporal.PlainDate.from('2024-01-31');
const result = start.add({ months: 1 }, { overflow: 'constrain' }); // clamps to 2024-02-29
// switch to 'reject' to throw instead of clamping when the day overflows
Both reach 2024-02-29, but Temporal lets you choose clamping versus an error, while date-fns bakes the clamp in.
Full production version: a DST-correct timezone task
The clearest place the choice bites is a daylight-saving-correct task, such as "9am every day in a user's zone across a transition." Temporal expresses this directly: build a ZonedDateTime in the user's zone and advance with add({ days: 1 }), which preserves the wall-clock 9am even when the day is 23 or 25 hours long. date-fns, operating on native Date, has no first-class zone, so the same task requires a companion (date-fns-tz) and careful handling to avoid the "9am drifts to 10am after spring-forward" bug that Date's zone-blindness invites. The task exposes the layer difference: zone-correctness is native to Temporal and bolted on for date-fns.
For a production system that does substantial zone- and calendar-sensitive work, that native support is the deciding factor — it removes an entire class of bug rather than patching it. For a system whose date needs are modest formatting and simple arithmetic on values that are already effectively UTC, date-fns's smaller footprint and gentler migration may be the pragmatic choice today, with Temporal as the direction of travel as it lands natively. The point is to match the tool to how zone- and calendar-heavy the domain actually is, and to be honest that date-fns operates within Date's constraints while Temporal replaces them.
Display "one day later, same local time" in America/New_York across the spring-forward boundary — the case that separates the two clearly.
// Temporal: ZonedDateTime keeps wall-clock time across the DST gap natively
import { Temporal } from '@js-temporal/polyfill';
export function sameTimeNextDay(iso: string, timeZone: string): string {
const zdt = Temporal.ZonedDateTime.from(`${iso}[${timeZone}]`);
// add({ days: 1 }) preserves the local clock time; the absolute span may be 23 or 25 hours
return zdt.add({ days: 1 }).toString();
}
sameTimeNextDay('2024-03-09T12:00:00-05:00', 'America/New_York');
// 2024-03-10T12:00:00-04:00[America/New_York] — still noon local, despite the lost hour
// date-fns + date-fns-tz: you must convert to zoned wall-clock, add, then convert back
import { addDays } from 'date-fns';
import { toZonedTime, fromZonedTime } from 'date-fns-tz';
export function sameTimeNextDay(date: Date, timeZone: string): Date {
const local = toZonedTime(date, timeZone); // a Date whose fields read as wall-clock in timeZone
const next = addDays(local, 1); // add a calendar day to the wall-clock value
return fromZonedTime(next, timeZone); // re-anchor to the correct absolute instant
}
The date-fns version is correct, but it requires a deliberate convert-add-convert dance that is easy to skip — and skipping it (calling addDays on the raw Date) silently adds 24 absolute hours, landing an hour off on a DST day. Temporal makes the wall-clock-preserving behavior the default.
Verification
import { Temporal } from '@js-temporal/polyfill';
const noon = Temporal.ZonedDateTime.from('2024-03-09T12:00:00-05:00[America/New_York]');
const next = noon.add({ days: 1 });
// Local clock time is preserved...
console.assert(next.hour === 12, 'should still be noon local time');
// ...even though only 23 absolute hours elapsed across the spring-forward gap.
const elapsed = next.since(noon, { largestUnit: 'hours' }).hours;
console.assert(elapsed === 23, `expected 23 absolute hours, got ${elapsed}`);
Decision guidance by use case
- Formatting only (no math): neither — use
Intl.DateTimeFormatdirectly. It is native, free, and handles locales and zones. - A few simple helpers, tight bundle budget, no timezone math: date-fns. Import only the functions you use and tree-shaking keeps it tiny.
- Timezone-correct scheduling, recurring events, DST-sensitive arithmetic: Temporal. The disambiguation and wall-clock semantics are exactly what this needs.
- Non-Gregorian calendars (Japanese, Hijri, Hebrew): Temporal. date-fns has no calendar-system model.
- Greenfield project targeting modern runtimes: Temporal, since native support removes the polyfill cost over time.
- Legacy app already deep in native
Date: date-fns is the lower-friction step; reach for Temporal where correctness bugs actually occur.
Many teams use both: Intl for display, date-fns for cheap utilities, and Temporal in the modules where timezone correctness is non-negotiable.
Common pitfalls
The first pitfall in choosing between date-fns and Temporal is treating them as interchangeable when they occupy different layers. date-fns is a toolbox of functions that operate on the native Date, so it inherits Date's limitations — a single mutable-ish UTC-instant type with implicit host-zone behavior and no first-class zone or civil-date model. Temporal is a new type system that replaces Date with purpose-built immutable types (Instant, ZonedDateTime, PlainDate). So the real question is not "which library formats faster" but "do I want to keep computing on Date with helpers, or move to types that make the zone and the kind-of-time explicit."
A second pitfall is underestimating the implicit-policy issue. date-fns's addMonths(new Date(2024, 0, 31), 1) clamps to February 29, but the policy is fixed and unconfigurable, and the Date it returns still carries host-zone semantics; Temporal's add({ months: 1 }, { overflow }) makes the overflow policy explicit and operates on a zoneless PlainDate when that is what you mean. A third is migration cost blindness: date-fns is a smaller step from existing Date code, while Temporal is a larger but more thorough fix. Choose date-fns to smooth over Date incrementally and Temporal to eliminate the underlying Date problems; do not expect date-fns to fix what is fundamentally a Date limitation.
- Calling
addDayson a rawDatefor "next day" in a fixed zone. It adds 24 absolute hours and lands an hour off across DST.- Wrong:
addDays(utcDate, 1)Right:toZonedTime→addDays→fromZonedTime, or use Temporal'sadd({ days: 1 }).
- Wrong:
- Counting Temporal as huge because of the polyfill. The ~18–22KB is one-time and shared; date-fns plus
date-fns-tzcan rival it once you need zones.- Fix: compare total bundle for your actual feature set, not the smallest possible date-fns import.
- Forgetting date-fns months are zero-indexed.
new Date(2024, 1, 1)is February, not January.- Fix: prefer
parseISO('2024-02-01')over the numeric constructor.
- Fix: prefer
Frequently Asked Questions
Is date-fns smaller than Temporal?
For a couple of helpers on plain dates, yes — tree-shaking can leave only a few hundred bytes. But once you add date-fns-tz for IANA timezone work, the size advantage shrinks toward Temporal's one-time polyfill cost. Compare the total bundle for the features you actually ship, and remember the polyfill disappears as native Temporal support spreads.
Can I use both date-fns and Temporal in one project?
Yes, and it is common. Use Intl for formatting, date-fns for inexpensive helpers on native Date, and Temporal in the specific modules that need DST-aware arithmetic or non-Gregorian calendars. Bridge between them through epoch milliseconds (date.getTime() / Temporal.Instant.fromEpochMilliseconds).
Should new projects skip date-fns and go straight to Temporal?
For greenfield code on modern runtimes, Temporal is the forward-looking choice: it fixes Date's mutability and timezone gaps at the type level, and native support removes the polyfill cost over time. date-fns remains a fine choice when you only need a handful of lightweight helpers and want zero conceptual overhead.
Should I choose date-fns or Temporal?
It depends on how zone- and calendar-heavy your work is. date-fns is a toolbox of helpers that operate on the native Date, so it is a smaller migration step but inherits Date's limitations — implicit host zone, no first-class zone or civil-date type, fixed overflow policies. Temporal is a new type system that replaces Date with immutable Instant, ZonedDateTime, and PlainDate, making the zone and kind-of-time explicit. Choose date-fns to smooth Date incrementally, Temporal to eliminate its underlying problems.
Can date-fns handle time zones as well as Temporal?
Not natively. date-fns operates on the native Date, which has no first-class zone, so zone-aware work needs the companion date-fns-tz and careful handling to avoid drift bugs across daylight-saving transitions. Temporal's ZonedDateTime models the zone directly, so a task like '9am daily across a DST change' is expressed with add({ days: 1 }) and stays correct by construction. For zone-heavy domains, Temporal's native support is the deciding factor.
Is date-fns or Temporal a smaller migration from existing Date code?
date-fns is the smaller step, because it works with the native Date objects you already have and just adds helper functions. Temporal is a larger but more thorough change, replacing Date with new types throughout. If your date needs are modest and your values are effectively UTC, date-fns may be pragmatic now; if you keep hitting zone and civil-date bugs, Temporal fixes them at the root and is the longer-term direction.