Context
Day.js and date-fns were both born to solve the same problem: Moment.js is large, mutable, and now in maintenance mode. But they solve it in opposite ways, and that opposition is what decides the choice. Day.js keeps Moment's mental model almost intact: you work with an object that wraps the date (dayjs()), chain operations on that object (.add(1, "day").startOf("month")), and format at the end. The difference from Moment is that this object is immutable (each operation returns a new instance) and the core fits in about 3kB gzipped, with extra features (time zones, localized formats, durations) coming in through plugins you register only when you need them. date-fns throws out the wrapper object: they are pure functions that operate directly on JavaScript's native Date. You don't instantiate anything, you import addDays, startOfDay, format and compose them. Because each function is an isolated import, the bundler tree-shakes and only what you actually called ends up in the build.
The deciding variable is the style you want in your code and where you're coming from. If you're migrating a Moment project and want the least friction possible, Day.js's chainable, near-identical API is the short path. If you're starting from scratch, value pure functions that compose well, and want to pay in bundle size only for the functions you use, date-fns is the more aligned model. In weekly npm downloads both are heavyweights: date-fns around 93 million and Day.js around 59 million.
When to choose Day.js
Day.js makes the most sense when you want a familiar, tiny API. If the team already thinks in Moment's model (an object representing an instant, with chainable methods), Day.js is practically a drop-in replacement: its API surface was designed to mirror Moment's, which makes migration close to mechanical in many cases. And the bundle cost is hard to beat: the roughly 3kB gzipped core covers parsing, manipulation, comparison, and formatting, without bringing anything you didn't ask for.
import dayjs from "dayjs";
const now = dayjs();
const nextWeek = now.add(7, "day").startOf("day");
console.log(nextWeek.format("YYYY-MM-DD"));
console.log(now.isBefore(nextWeek)); // true, now is unchanged
In the example, now never changes: each operation (add, startOf) returns a new instance, which eliminates an entire class of accidental-mutation bugs that haunted Moment. What's left out of the core comes in through plugins: utc and timezone for zones, localizedFormat for locale-aware formats, relativeTime for "3 hours ago". This is a double-edged sword: it keeps the core lean, but it means anything beyond the basics requires explicitly registering a plugin (dayjs.extend(...)) before use. For everyday date formatting and arithmetic, with minimal weight and a near-zero learning curve for anyone coming from Moment, Day.js is the pragmatic choice.
When to choose date-fns
date-fns makes the most sense when you want the functional model and the smallest possible bundle scaled to real usage. There's no wrapper object and no instance to maintain: each operation is a pure function that takes a Date, returns a new value, and never mutates the input. Because you import function by function, the bundler's tree-shaking guarantees that only what you called reaches the final build. In a project that uses three date functions, you pay for three functions, not for a whole library.
import { addDays, startOfDay, format, isBefore } from "date-fns";
const now = new Date();
const nextWeek = startOfDay(addDays(now, 7));
console.log(format(nextWeek, "yyyy-MM-dd"));
console.log(isBefore(now, nextWeek)); // true, now is unchanged
The same calculation as the previous example, now by composing functions over a native Date. This style fits naturally into functional codebases and makes each step easy to test in isolation. Time zones were historically handled by the companion package date-fns-tz, but v4 brought first-class time zone support with the @date-fns/tz package and the TZDate class, closing the most-cited gap against the library. i18n follows the same import-only-what-you-need principle: each locale is a separate import. The cost of the model is verbosity: with no chaining, long calculations turn into nested functions or intermediate variables, and anyone coming from Moment has to readjust from "object with methods" to "functions that take and return Date".
Verdict
Reduced to its essentials, the choice is about style and origin. If you're migrating from Moment or want a chainable, immutable, tiny API without much thought, Day.js delivers that in about 3kB with an API map almost identical to what the team already knows. Features beyond the basics via plugins are a fair price for such a lean core, and for most date formatting and arithmetic cases Day.js gets it done without ceremony.
If you're starting from scratch, prefer pure functions that compose well, and want the bundle to grow only as much as you use, date-fns is the more consistent model, and v4 removed the biggest historical asterisk by bringing first-class time zones. Adoption reflects this: date-fns leads in weekly downloads, but both are safe, actively maintained choices. Don't swap one for the other without a reason: choose by the style the rest of your codebase already uses (chainable object or functional composition), and let the decision follow the architecture you already have.