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
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
- 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.