Contexto
Day.js e date-fns nasceram para resolver o mesmo problema: o Moment.js é grande, mutável e hoje está em modo de manutenção. Mas resolvem esse problema de formas opostas, e é essa oposição que decide a escolha. Day.js mantém quase intacto o modelo mental do Moment: você trabalha com um objeto que envolve a data (dayjs()), encadeia operações nesse objeto (.add(1, "day").startOf("month")) e formata no final. A diferença em relação ao Moment é que esse objeto é imutável (cada operação devolve uma nova instância) e o core cabe em cerca de 3kB gzipado, com recursos extras (timezone, formatos localizados, durações) entrando via plugins que você registra só quando precisa. date-fns joga fora o objeto envelope: são funções puras que operam diretamente sobre o Date nativo do JavaScript. Você não instancia nada, você importa addDays, startOfDay, format e as compõe. Como cada função é um import isolado, o bundler faz tree-shaking e só entra no build aquilo que você de fato chamou.
A variável decisiva é o estilo que você quer no código e de onde você está vindo. Se está migrando de um projeto Moment e quer o menor atrito possível, a API chainable e quase idêntica do Day.js é o caminho curto. Se está começando do zero, valoriza funções puras que compõem bem e quer pagar no bundle só pelas funções que usa, date-fns é o modelo mais alinhado. Em downloads semanais no npm as duas são pesos-pesados: date-fns em torno de 93 milhões e Day.js em torno de 59 milhões.
Quando escolher Day.js
Day.js faz mais sentido quando você quer uma API familiar e minúscula. Se o time já pensa no modelo do Moment (um objeto que representa um instante, com métodos encadeáveis), o Day.js é praticamente um substituto direto: a superfície da API foi desenhada para espelhar a do Moment, o que torna a migração quase mecânica em muitos casos. E o custo no bundle é difícil de bater: o core em torno de 3kB gzipado cobre parsing, manipulação, comparação e formatação, sem trazer nada que você não pediu.
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
No exemplo, now nunca muda: cada operação (add, startOf) devolve uma nova instância, o que elimina uma classe inteira de bugs de mutação acidental que assombrava o Moment. O que fica de fora do core entra por plugins: utc e timezone para fusos, localizedFormat para formatos por locale, relativeTime para "há 3 horas". Isso é uma faca de dois gumes: mantém o core enxuto, mas significa que qualquer coisa além do básico exige registrar um plugin explicitamente (dayjs.extend(...)) antes de usar. Para formatação e aritmética de datas do dia a dia, com peso mínimo e uma curva quase nula para quem vem do Moment, Day.js é a escolha pragmática.
Quando escolher date-fns
date-fns faz mais sentido quando você quer o modelo funcional e o menor bundle possível dimensionado pelo uso real. Não existe objeto envelope nem instância a manter: cada operação é uma função pura que recebe um Date, devolve um novo valor e nunca muta a entrada. Como você importa função por função, o tree-shaking do bundler garante que só o que foi chamado chega ao build final. Num projeto que usa três funções de datas, você paga por três funções, não por uma biblioteca inteira.
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
O mesmo cálculo do exemplo anterior, agora por composição de funções sobre um Date nativo. Esse estilo se encaixa naturalmente em bases de código funcionais e facilita testar cada passo isoladamente. Timezone historicamente ficava a cargo do pacote companheiro date-fns-tz, mas a v4 trouxe suporte de fuso de primeira classe com o pacote @date-fns/tz e a classe TZDate, fechando a lacuna mais citada contra a biblioteca. i18n segue o mesmo princípio de importar só o necessário: cada locale é um import separado. O custo do modelo é verbosidade: sem encadeamento, cálculos longos viram funções aninhadas ou variáveis intermediárias, e quem vem do Moment precisa reajustar o raciocínio de "objeto com métodos" para "funções que recebem e devolvem Date".
Veredito
Reduzida ao essencial, a escolha é sobre estilo e origem. Se você está migrando de Moment ou quer uma API chainable, imutável e minúscula sem pensar muito, Day.js entrega isso com cerca de 3kB e um mapa de API quase idêntico ao que o time já conhece. Recursos além do básico via plugins são um preço justo por um core tão enxuto, e para a maioria dos casos de formatação e aritmética de datas o Day.js resolve sem cerimônia.
Se você está começando do zero, prefere funções puras que compõem bem e quer que o bundle cresça só na medida do que usa, date-fns é o modelo mais consistente, e a v4 removeu o maior asterisco histórico ao trazer timezone de primeira classe. A adoção reflete isso: date-fns lidera em downloads semanais, mas os dois são escolhas seguras e ativamente mantidas. Não troque um dos dois pelo outro sem motivo: escolha pelo estilo que o resto da base já usa (objeto chainable ou composição funcional), e deixe a decisão seguir a arquitetura que você já tem.