Bundle analyzer: as dependências que sempre estouram o JavaScript
Quando o peso está no pacote compartilhado, o problema não é uma rota — é uma biblioteca. As seis que mais aparecem e o que colocar no lugar.
Ambiente testado
- Next.js 15.3
- @next/bundle-analyzer 15.3
- Node 22
Contexto: o que estava rodando
Um SaaS com quinze rotas. Todas lentas no celular, de forma uniforme — o que já é uma pista: quando tudo é lento na mesma medida, o peso não está numa página, e sim no que todas carregam.
O sintoma
Route (app) Size First Load JS
┌ ○ / 1.2 kB 341 kB
├ ○ /clientes 4.8 kB 345 kB
├ ○ /faturas 3.1 kB 344 kB
└ ○ /configuracoes 2.4 kB 343 kB
+ First Load JS shared by all 340 kB
├ chunks/framework-a3f1.js 45 kB
├ chunks/main-8b21.js 34 kB
└ chunks/vendor-c41d.js 261 kBO tamanho próprio de cada rota é de poucos kilobytes. Os 340 KB compartilhados são o problema, e 261 KB deles estão num único pedaço de dependências.
Diagnóstico
Ligar o analisador
import withBundleAnalyzer from '@next/bundle-analyzer';
export default withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' })({});ANALYZE=true npm run build
Ele abre três relatórios: client, nodejs e edge. Só o client importa
para o que o usuário baixa — os outros dois rodam no servidor e o tamanho deles
afeta cold start, não o navegador.
As seis que mais aparecem
Depois de rodar isso em vários projetos, a lista de culpados quase não muda.
1. Biblioteca de datas completa. moment inteiro, ou date-fns importado do
índice. Aparece porque alguém precisou formatar uma data em 2023.
// 70 KB+ para formatar uma data
import moment from 'moment';
moment(data).format('DD/MM/YYYY');
// 0 KB: nativo, com fuso e locale corretos
new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }).format(data);
Intl resolve formatação, fuso e plural. Para aritmética de datas — somar meses,
diferença entre datas — date-fns com import por caminho é o próximo degrau, e
entra só o que você usa.
2. Lodash inteiro. import _ from 'lodash' traz o pacote completo.
// antes
import _ from 'lodash';
const unicos = _.uniqBy(itens, 'id');
// depois: nativo, e mais claro
const unicos = [...new Map(itens.map((i) => [i.id, i])).values()];
Quando a função não tem equivalente nativo confortável, lodash-es com import
nomeado permite eliminação de código morto.
3. Biblioteca de ícones importada pelo índice. Este é o mais traiçoeiro:
// Puxa a árvore de milhares de ícones antes do tree shaking resolver
import { FiUser, FiHome } from 'react-icons/fi';
A correção mais confiável é o Next fazer a reescrita por você:
export default {
experimental: {
// Reescreve imports de barril para caminhos diretos em tempo de build.
optimizePackageImports: ['react-icons', 'lucide-react', 'date-fns', 'lodash-es'],
},
};4. Componentes pesados carregados sempre. Editor de texto rico, gráfico, mapa, leitor de PDF. Nenhum deles precisa estar no primeiro byte:
const Editor = dynamic(() => import('./Editor'), {
ssr: false,
loading: () => <div className="h-96 animate-pulse rounded bg-neutral-100" />,
});
5. SDK de servidor vazando para o cliente. SDKs de nuvem, cliente de banco, biblioteca de e-mail. Chegam no pacote do navegador porque um componente cliente importou um utilitário que os importa.
// Transforma o vazamento em erro de compilação, com o arquivo culpado apontado.
import 'server-only';6. Duas cópias da mesma biblioteca. Duas versões incompatíveis instaladas por dependências diferentes, e as duas vão para o pacote:
npm ls date-fns
projeto@0.1.0
├─┬ react-day-picker@8.10.1
│ └── date-fns@2.30.0
└── date-fns@4.1.0Duas cópias, cerca de 40 KB desperdiçados. Resolve-se alinhando as versões ou
usando overrides no package.json.
A solução
-
Medir o que cada troca rendeu, uma de cada vez.
Mudar cinco coisas e rodar o build no fim não diz o que funcionou. Um script simples registra a linha que importa:
package.json{ "scripts": { "size": "next build 2>&1 | grep 'First Load JS shared'" } } -
Substituir por nativo onde der.
Intlpara datas, números e moeda.structuredCloneno lugar de clone profundo.Array.prototype.at,Object.groupBy,URLSearchParams. Cada uma remove uma dependência inteira em troca de zero byte. -
Ativar
optimizePackageImportspara o que sobrar.Vale para qualquer pacote que exporte muitos símbolos por um índice. É a melhoria de maior retorno por linha de configuração do Next.
-
Empurrar o resto para carregamento sob demanda.
O critério não é o tamanho da biblioteca — é quando ela é necessária. Uma biblioteca de 200 KB usada por 5% dos usuários numa tela específica é um
dynamic(), não um problema. -
Travar o resultado no CI.
Sem trava, o pacote volta a crescer no terceiro sprint. Um teste que falha quando passa do orçamento:
scripts/orcamento-bundle.mjsimport { execSync } from 'node:child_process'; const TETO_KB = 180; const saida = execSync('npx next build', { encoding: 'utf8' }); const achado = /First Load JS shared by all\s+([\d.]+)\s*kB/.exec(saida); if (!achado) { console.error('não consegui ler o tamanho do build'); process.exit(1); } const atual = Number(achado[1]); console.log(`pacote compartilhado: ${atual} kB (teto ${TETO_KB} kB)`); if (atual > TETO_KB) { console.error('acima do orçamento — rode ANALYZE=true npm run build'); process.exit(1); }O teto deve ficar um pouco acima do valor atual. Ele não existe para forçar melhoria, e sim para impedir regressão silenciosa.
Como confirmar que resolveu
A linha do compartilhado:
+ First Load JS shared by all 118 kB
├ chunks/framework-a3f1.js 45 kB
├ chunks/main-8b21.js 34 kB
└ chunks/vendor-9e02.js 39 kBDe 340 KB para 118 KB, sem remover funcionalidade.
Nenhum bloco de servidor no relatório do cliente. Rode o analisador de novo e procure por nomes de SDK, cliente de banco ou variáveis de ambiente.
Sem cópias duplicadas:
npm ls --all 2>/dev/null | grep -E "deduped|date-fns|react@" | head -20
Meça o efeito real. Menos bytes só vale se o usuário sentir. Com CPU limitada em 4x no painel Performance, o tempo de avaliação de script durante o carregamento é o número que precisa cair — e ele reaparece no INP.
Armadilhas que sobram depois disso
Gzip esconde repetição. Duas versões da mesma biblioteca comprimem bem juntas e podem parecer baratas no número final — mas o navegador ainda precisa descomprimir e executar as duas. Custo de CPU não aparece no tamanho.
dynamic() demais fragmenta o carregamento. Vinte pedaços pequenos custam
vinte requisições. Agrupe o que sempre é usado junto.
Polyfill para navegador que você não suporta. O browserslist do
package.json decide quanto código de compatibilidade entra. Um alvo antigo
esquecido acrescenta dezenas de kilobytes para ninguém.
O maior ganho pode não estar no pacote. Se o peso vier de um componente cliente que não precisava ser cliente, nenhuma troca de biblioteca resolve — a correção é mover a fronteira, que é outro problema com outro diagnóstico.
Às vezes o peso é o próprio framework. Numa página majoritariamente de conteúdo — documentação, blog, institucional — nem o melhor orçamento de pacote chega perto de um gerador estático, que não envia JavaScript nenhum.
Continue por aqui
Performance Web
INP acima de 200 ms: achando a long task que trava o clique
O INP mede o clique mais lento da sessão, não a média. Como achar exatamente qual interação e qual linha de código estão travando a thread principal.
Performance Web
O 'use client' que dobrou o bundle: onde fica a fronteira
A diretiva não marca um arquivo, marca uma fronteira. Tudo que ela importa desce junto para o navegador — inclusive o que você jurava ser código de servidor.
Performance Web
Web Push do zero: Service Worker, VAPID e o pedido de permissão
A implementação inteira, do registro do worker ao envio. E o erro de UX que queima a permissão do usuário para sempre, sem possibilidade de desfazer.