Otimização de Bundle JavaScript: Code Splitting, Tree Shaking e Técnicas Práticas

Descobre como reduzir drasticamente o teu bundle JavaScript com code splitting, tree shaking e dynamic imports. Inclui exemplos de código prontos a usar, ferramentas de análise e dicas de performance budgets para 2026.

Introdução — O JavaScript é o Maior Inimigo da Performance Web (e o Maior Aliado, Se Souberes Usá-lo)

Se acompanhaste a nossa série sobre Core Web Vitals — onde cobrimos o INP, o LCP e o CLS — já sabes que estas três métricas dependem, em maior ou menor grau, de uma coisa: quanto JavaScript envias para o browser e quanto tempo demora a ser executado. O JavaScript é responsável pela interatividade, pela renderização dinâmica e por praticamente toda a lógica do lado do cliente. Mas também é, de longe, o recurso mais caro por byte que o browser precisa de processar.

E o problema não é apenas o download. Cada kilobyte de JavaScript precisa de ser descarregado, descomprimido, parsed, compilado e executado — tudo isto na main thread, a mesma thread que gere cliques, scrolls e animações. Enquanto o browser está ocupado a executar JavaScript, simplesmente não consegue responder ao utilizador. É por isso que 500KB de JavaScript são muito mais prejudiciais do que 500KB de imagens.

Os dados recentes não mentem. O HTTP Archive mostra que o site mediano em 2025 envia mais de 500KB de JavaScript comprimido — que se transforma em cerca de 1,5MB após descompressão. Num telemóvel de gama média com ligação instável, isto traduz-se em segundos de main thread bloqueada. O INP sofre, o LCP atrasa, e o utilizador fica frustrado.

Neste guia, vamos abordar as técnicas que realmente funcionam para reduzir o tamanho e o impacto dos bundles JavaScript: code splitting, tree shaking, dynamic imports, otimização de dependências e mais. Cada técnica vem com exemplos de código que podes copiar e adaptar. Quer uses React, Next.js, Vue ou vanilla JavaScript — os princípios são os mesmos.

Bom, vamos a isto.

Diagnosticar Antes de Otimizar — Ferramentas de Análise de Bundle

Antes de alterar uma única linha de código, precisas de perceber exatamente o que está dentro do teu bundle. Otimizar às cegas é como tentar emagrecer sem balança — acabas a fazer esforço nos sítios errados.

Webpack Bundle Analyzer

O Webpack Bundle Analyzer é a ferramenta de referência. Gera um mapa interativo (treemap) que mostra visualmente o tamanho de cada módulo dentro do bundle. Honestamente, é surpreendente quantas equipas descobrem que 40-60% do bundle vem de dependências que nem sabiam que estavam lá.

# Instalação
npm install --save-dev webpack-bundle-analyzer

# Em webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({
      analyzerMode: 'static',
      reportFilename: 'bundle-report.html',
      openAnalyzer: false
    })
  ]
};

Se usas Next.js, o processo é igualmente simples:

# Instalação
npm install --save-dev @next/bundle-analyzer

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true'
});

module.exports = withBundleAnalyzer({
  // restante da configuração
});
# Executar análise
ANALYZE=true npm run build

Chrome DevTools — Coverage Tab

A Coverage tab do Chrome DevTools mostra, em tempo real, quanto do JavaScript (e CSS) carregado é efetivamente executado. Abre as DevTools, vai a More tools → Coverage, e recarrega a página. Vais ver barras vermelhas (código não utilizado) e verdes (código executado).

Em muitos sites, 60-70% do JavaScript carregado na página inicial nunca é executado. Puro desperdício.

Lighthouse e PageSpeed Insights

O Lighthouse tem dois audits particularmente relevantes:

  • "Reduce unused JavaScript" — identifica scripts com código não utilizado e estima a poupança em KB
  • "Reduce JavaScript execution time" — mostra quanto tempo cada script demora a ser executado na main thread

A minha recomendação? Usa estas ferramentas em conjunto: o Bundle Analyzer para perceber o que está no bundle, a Coverage tab para perceber quanto é realmente usado, e o Lighthouse para medir o impacto real na performance. Cada uma conta uma parte diferente da história.

Bundlephobia — Avaliar Antes de Instalar

O Bundlephobia permite verificar o tamanho de qualquer pacote npm antes de o instalar. Mostra o tamanho minificado, minificado + gzip, e se o pacote suporta tree shaking. Deveria ser passo obrigatório antes de qualquer npm install — e sinceramente, devia estar integrado em todos os code reviews.

Code Splitting — Enviar Apenas o Que é Necessário

O code splitting é, provavelmente, a técnica com maior impacto na redução do tempo de carregamento inicial. O conceito é simples: em vez de enviar todo o JavaScript da aplicação num único ficheiro monolítico, divides o código em chunks menores que são carregados apenas quando necessários.

Parece óbvio quando dito assim, mas a realidade é que muitos projetos ainda enviam tudo de uma vez.

Code Splitting Baseado em Rotas

A forma mais eficaz de code splitting é por rotas. Se a tua aplicação tem 20 páginas, o utilizador que visita a homepage não precisa de descarregar o código da página de contacto, do dashboard ou das definições. Cada rota deve corresponder a um chunk separado.

Em React com React Router, a implementação é direta:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Cada import dinâmico gera um chunk separado
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Contact = lazy(() => import('./pages/Contact'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div className="loading-spinner" />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="/contact" element={<Contact />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

O React.lazy() aceita uma função que retorna um import() dinâmico. O Webpack (ou outro bundler) deteta automaticamente estas chamadas e cria chunks separados. O <Suspense> mostra um fallback enquanto o chunk carrega.

Agora, em Next.js, o code splitting por rotas é automático. Cada ficheiro dentro de app/ (ou pages/) gera automaticamente um chunk separado — não precisas de fazer nada. Mas podes (e deves) otimizar ainda mais com dynamic imports para componentes pesados dentro de cada página.

Code Splitting Baseado em Componentes

Dentro de uma mesma página, podes ter componentes pesados que não são necessários imediatamente. Exemplos típicos: modais, editores de texto rico, gráficos, mapas e carrosséis de imagens. Estes são candidatos perfeitos para code splitting ao nível do componente.

// Next.js — dynamic import com SSR desativado
import dynamic from 'next/dynamic';

const RichTextEditor = dynamic(
  () => import('../components/RichTextEditor'),
  {
    loading: () => <div className="skeleton-editor" />,
    ssr: false // Não renderizar no servidor
  }
);

const ChartDashboard = dynamic(
  () => import('../components/ChartDashboard'),
  {
    loading: () => <div className="skeleton-chart" />,
    ssr: false
  }
);

export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <RichTextEditor />
      <ChartDashboard />
    </main>
  );
}

A opção ssr: false é importante para componentes que dependem de APIs do browser (como window ou document) e que não fazem sentido serem renderizados no servidor.

Vendor Splitting — Separar Código de Terceiros

As bibliotecas de terceiros (React, Lodash, Moment.js, etc.) mudam com menos frequência do que o teu código. Ao separá-las num chunk próprio — o chamado "vendor bundle" — o browser pode armazená-las em cache entre deployments, evitando re-downloads desnecessários.

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10
        },
        // Separar frameworks grandes num chunk próprio
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
          name: 'react-vendor',
          chunks: 'all',
          priority: 20
        }
      }
    }
  }
};

Esta configuração cria dois chunks de vendor: um para React/ReactDOM (que quase nunca muda) e outro para as restantes dependências. O resultado? Caching muito mais eficiente e menos re-downloads para os teus utilizadores.

Tree Shaking — Eliminar Código Morto Automaticamente

O tree shaking é o processo pelo qual o bundler analisa estaticamente o teu código e remove tudo o que não é utilizado. O nome vem da metáfora de "abanar a árvore" para deixar cair as folhas mortas — neste caso, código morto.

É uma daquelas funcionalidades que parece magia quando funciona bem.

Como Funciona

O tree shaking depende da análise estática de módulos ES (import/export). Ao contrário de require() do CommonJS, que é dinâmico e só pode ser resolvido em runtime, os imports ES são estáticos — o bundler sabe, em tempo de build, exatamente quais exports são usados e quais não são.

// utils.js — módulo com 5 funções
export function formatDate(date) { /* ... */ }
export function formatCurrency(value) { /* ... */ }
export function formatPercentage(value) { /* ... */ }
export function slugify(text) { /* ... */ }
export function truncate(text, length) { /* ... */ }

// app.js — só usa 2 das 5 funções
import { formatDate, formatCurrency } from './utils';

// Com tree shaking ativo, formatPercentage, slugify e truncate
// são removidos do bundle final

Garantir que o Tree Shaking Funciona

Na prática, muitas equipas pensam que o tree shaking está a funcionar quando, na realidade, não está. Já vi isto acontecer mais vezes do que gostava de admitir. Aqui estão os requisitos para que funcione de facto:

  1. Usar módulos ES (import/export), nunca require()
  2. Ativar modo production no Webpack (define automaticamente usedExports: true e minimize: true)
  3. Marcar pacotes como livres de side effects no package.json
  4. Importar seletivamente, nunca com wildcard
// package.json — declarar que o pacote é tree-shakeable
{
  "name": "minha-app",
  "sideEffects": false
}

// Ou, se tiver ficheiros com side effects reais (como CSS):
{
  "sideEffects": ["*.css", "*.scss"]
}

A propriedade sideEffects: false diz ao bundler que pode remover com segurança qualquer export não utilizado. Sem isto, o Webpack assume conservadoramente que cada módulo pode ter efeitos colaterais e mantém tudo no bundle — mesmo o que ninguém usa.

Armadilhas Comuns do Tree Shaking

Há duas armadilhas que apanham muitos developers (e que valem a pena memorizar):

1. Barrel files (index.js) — Ficheiros que re-exportam tudo de vários módulos. Parecem práticos, mas podem sabotar o tree shaking em alguns bundlers:

// components/index.js — barrel file (potencialmente problemático)
export { Button } from './Button';
export { Modal } from './Modal';
export { Chart } from './Chart';
export { RichEditor } from './RichEditor';

// Mau — pode forçar o bundler a processar TODOS os módulos
import { Button } from './components';

// Melhor — import direto do módulo específico
import { Button } from './components/Button';

2. Imports com wildcard — Usar import * impede completamente o tree shaking:

// Mau — importa TUDO, tree shaking impossível
import * as Icons from 'lucide-react';

// Bom — importa apenas o necessário
import { Search, Menu, X } from 'lucide-react';

Dynamic Imports e Lazy Loading — Carregar Sob Demanda

Os dynamic imports são a base técnica do code splitting e do lazy loading. Em vez de incluir um módulo no bundle principal, usas import() para o carregar assincronamente quando é necessário.

É a diferença entre levar toda a mala de viagem para o escritório ou ir buscar o que precisas ao cacifo quando precisas.

Lazy Loading de Componentes Pesados

O caso de uso mais comum é carregar componentes pesados apenas quando o utilizador interage com eles. Um modal de edição, por exemplo, não precisa de estar no bundle inicial — só é necessário quando alguém clica em "Editar":

import { useState, Suspense, lazy } from 'react';

// O chunk do EditModal só é descarregado quando o utilizador
// clica no botão pela primeira vez
const EditModal = lazy(() => import('./EditModal'));

function ProductPage({ product }) {
  const [showEditor, setShowEditor] = useState(false);

  return (
    <div>
      <h1>{product.name}</h1>
      <button onClick={() => setShowEditor(true)}>
        Editar Produto
      </button>

      {showEditor && (
        <Suspense fallback={<div>A carregar editor...</div>}>
          <EditModal
            product={product}
            onClose={() => setShowEditor(false)}
          />
        </Suspense>
      )}
    </div>
  );
}

Lazy Loading de Bibliotecas Pesadas

Não são apenas componentes — podes também carregar bibliotecas inteiras sob demanda. Isto é particularmente útil para bibliotecas pesadas que só são necessárias em contextos específicos:

// Validação de passwords — só carrega quando o utilizador
// começa a escrever no campo de password
async function validatePasswordStrength(password) {
  const { default: zxcvbn } = await import('zxcvbn');
  return zxcvbn(password);
}

// Exportar para CSV — só carrega quando o utilizador clica
async function exportToCSV(data) {
  const { unparse } = await import('papaparse');
  const csv = unparse(data);
  downloadFile(csv, 'export.csv', 'text/csv');
}

// Syntax highlighting — só carrega quando um bloco
// de código entra no viewport
async function highlightCode(element) {
  const hljs = await import('highlight.js/lib/core');
  const javascript = await import('highlight.js/lib/languages/javascript');
  hljs.default.registerLanguage('javascript', javascript.default);
  hljs.default.highlightElement(element);
}

Cada um destes import() dinâmicos gera um chunk separado que só é descarregado quando a função é chamada. Uma biblioteca como o zxcvbn tem cerca de 400KB — carregá-la no bundle inicial quando 90% dos visitantes nem sequer tocam no campo de password seria um desperdício absurdo.

Prefetch para Antecipar Necessidades

Aqui está um truque que poucos aproveitam: combinar lazy loading com prefetch para antecipar o que o utilizador vai precisar a seguir. O Webpack suporta magic comments para isto:

// O chunk é pré-carregado em idle time, mas não executado
// até ser efetivamente necessário
const Analytics = lazy(() =>
  import(/* webpackPrefetch: true */ './Analytics')
);

// webpackPreload para recursos críticos da página atual
const HeroVideo = lazy(() =>
  import(/* webpackPreload: true */ './HeroVideo')
);

A diferença entre os dois? webpackPrefetch descarrega o chunk durante idle time (baixa prioridade, para navegação futura), enquanto webpackPreload descarrega em paralelo com o bundle principal (alta prioridade, para a página atual). Saber quando usar cada um faz uma diferença real.

Otimização de Dependências de Terceiros — Onde Estão os Verdadeiros Quilobytes

Na maioria dos projetos, as dependências de terceiros representam 60-80% do bundle total. É aqui que estão os ganhos maiores — e, felizmente, os mais fáceis de conseguir.

Auditar o Que Tens

Começa por correr o Bundle Analyzer e identificar os maiores culpados. Os suspeitos habituais em 2026:

  • moment.js (~300KB com locales) — substitui por date-fns (~20KB tree-shaked) ou dayjs (~7KB)
  • lodash (importação completa ~70KB) — importa funções individuais ou substitui por métodos nativos
  • chart.js / recharts — considera carregar dinamicamente, não no bundle inicial
  • bibliotecas de ícones (Material Icons, FontAwesome) — importa apenas os ícones que usas, nunca a coleção completa

A sério, se ainda tens o moment.js no teu projeto em 2026, faz essa migração já. A diferença é brutal.

Substituir Imports Pesados por Imports Seletivos

// Mau — importa toda a biblioteca (70KB+)
import _ from 'lodash';
const result = _.get(data, 'user.name');
const sorted = _.sortBy(items, 'date');

// Bom — importa apenas as funções necessárias (~4KB cada)
import get from 'lodash/get';
import sortBy from 'lodash/sortBy';
const result = get(data, 'user.name');
const sorted = sortBy(items, 'date');

// Ainda melhor — usa métodos nativos quando possível
const result = data?.user?.name; // optional chaining nativo
const sorted = [...items].sort((a, b) =>
  new Date(a.date) - new Date(b.date)
);

Verificar Tamanho Antes de Instalar

Cria a disciplina de verificar o tamanho no Bundlephobia antes de cada npm install. Algumas comparações que falam por si:

  • moment (289KB min) vs dayjs (6.5KB min) — mesma funcionalidade básica
  • axios (29KB min) vs ky (3.8KB min) — HTTP requests
  • uuid (14KB min) vs nanoid (1KB min) — geração de IDs
  • classnames (1.1KB min) vs clsx (0.5KB min) — concatenação de classes CSS

Estes números parecem pequenos individualmente, mas somam-se mais rápido do que imaginas. Um projeto com 30-50 dependências pode facilmente poupar 200-400KB de bundle apenas com substituições inteligentes.

Compressão, Minificação e Entrega Otimizada

Mesmo depois do code splitting e tree shaking, os ficheiros finais ainda podem (e devem) ser reduzidos com compressão e minificação.

Minificação com Terser (ou SWC)

A minificação remove espaços, comentários, renomeia variáveis e otimiza código. O Webpack em modo production já faz minificação com o Terser por defeito. Se usas Vite, o esbuild trata disso automaticamente. E em Next.js 14+, o SWC (escrito em Rust) substituiu o Terser — é cerca de 7x mais rápido, o que faz uma diferença enorme em projetos grandes.

Compressão Brotli e Gzip

A compressão acontece ao nível do servidor/CDN e reduz o tamanho dos ficheiros durante a transmissão. Brotli comprime ~15-25% melhor que Gzip para JavaScript, e é suportado por todos os browsers modernos.

# Nginx — ativar Brotli e Gzip
# /etc/nginx/nginx.conf

# Gzip (fallback)
gzip on;
gzip_types application/javascript text/css application/json;
gzip_min_length 1000;

# Brotli (preferido)
brotli on;
brotli_types application/javascript text/css application/json;
brotli_comp_level 6;  # 1-11, 6 é um bom equilíbrio

Uma alternativa que recomendo bastante é pré-comprimir durante o build em vez de comprimir em tempo real no servidor:

# Instalar plugin de compressão
npm install --save-dev compression-webpack-plugin

// webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');
const zlib = require('zlib');

module.exports = {
  plugins: [
    // Gzip
    new CompressionPlugin({
      algorithm: 'gzip',
      test: /\.(js|css|html|svg)$/
    }),
    // Brotli
    new CompressionPlugin({
      algorithm: 'brotliCompress',
      test: /\.(js|css|html|svg)$/,
      compressionOptions: {
        params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
      },
      filename: '[path][base].br'
    })
  ]
};

Com pré-compressão, o servidor serve ficheiros .br ou .gz diretamente do disco, sem gastar CPU a comprimir em cada request. E o nível de Brotli pode ir a 11 (máximo) porque não há pressão de tempo — o build demora mais uns segundos, mas cada request fica mais leve.

React Server Components e Frameworks Modernos — O Futuro é Menos JavaScript

Em 2026, a tendência mais significativa na otimização de bundles não é comprimir melhor — é enviar menos JavaScript ao browser, ponto. E os React Server Components (RSC) são a expressão mais prática desta filosofia.

React Server Components no Next.js

Os RSC executam no servidor e enviam apenas o HTML resultante para o browser — sem incluir o código do componente no bundle JavaScript do cliente. Uma página de listagem de produtos que como SPA tradicional enviaria ~200KB de JavaScript pode enviar apenas ~30KB com Server Components.

Estamos a falar de reduções de 50-90% em JavaScript do lado do cliente. Não é pouco.

// app/products/page.tsx — Server Component (padrão no Next.js App Router)
// Este código NUNCA é enviado para o browser
import { getProducts } from '@/lib/db';

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <main>
      <h1>Produtos</h1>
      <ul>
        {products.map(product => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>{product.price} €</p>
          </li>
        ))}
      </ul>
      {/* Apenas este componente interativo vai para o bundle */}
      <AddToCartButton />
    </main>
  );
}

// components/AddToCartButton.tsx — Client Component
'use client'; // Marca como componente do cliente
import { useState } from 'react';

export function AddToCartButton() {
  const [added, setAdded] = useState(false);
  return (
    <button onClick={() => setAdded(true)}>
      {added ? 'Adicionado ✓' : 'Adicionar ao carrinho'}
    </button>
  );
}

A regra é simples: tudo o que não precisa de interatividade no browser deve ser Server Component. Apenas componentes com useState, useEffect, event handlers ou APIs do browser precisam da diretiva 'use client'.

Partial Prerendering (Next.js 15)

O Partial Prerendering vai ainda mais longe: divide a página num shell estático (servido pela CDN em milissegundos) e partes dinâmicas que são transmitidas via streaming do servidor. O resultado é um LCP até 70% melhor comparado com SSR completo, porque o utilizador vê conteúdo estático instantaneamente enquanto as partes dinâmicas vão carregando.

Alternativas Além do React

Se não usas React, não fiques de fora. Outros frameworks oferecem abordagens igualmente interessantes:

  • Astro — zero JavaScript por defeito, hidratação parcial apenas onde necessário (a famosa "islands architecture")
  • Qwik — "resumability" em vez de hidratação, carrega JavaScript apenas em resposta a interações do utilizador
  • SvelteKit — compilação em tempo de build que elimina o overhead do runtime framework

Qualquer um destes merece atenção se estás a começar um projeto novo.

Quebrar Long Tasks — Manter a Main Thread Responsiva

Mesmo com bundles otimizados, a execução do JavaScript pode bloquear a main thread e degradar o INP. Qualquer task que demore mais de 50ms é considerada uma "long task" pelo browser e prejudica a responsividade.

scheduler.yield() — A Solução Moderna

O scheduler.yield() é a API recomendada em 2026 para ceder controlo à main thread durante tarefas longas. A grande vantagem sobre setTimeout(0)? A continuação da tarefa vai para a frente da fila (não para o fim), mantendo a prioridade.

// Processar uma lista grande sem bloquear a main thread
async function processLargeList(items) {
  const results = [];

  for (let i = 0; i < items.length; i++) {
    results.push(expensiveTransform(items[i]));

    // A cada 10 itens, ceder à main thread
    if (i % 10 === 0) {
      await scheduler.yield();
    }
  }

  return results;
}

// Fallback cross-browser (progressão elegante)
async function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    await scheduler.yield();
  } else {
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

Em março de 2026, o scheduler.yield() está disponível em browsers baseados em Chromium (Chrome, Edge, Opera). Para os restantes, o fallback com setTimeout(0) funciona — menos eficiente, mas infinitamente melhor do que não ceder de todo.

Performance Budgets — Prevenir em Vez de Remediar

Tudo o que vimos até agora é, de certa forma, reativo — corrigimos problemas existentes. Mas a melhor otimização é prevenir que o bundle cresça descontroladamente em primeiro lugar. É exatamente para isto que servem os performance budgets.

Definir Limites no Webpack

// webpack.config.js
module.exports = {
  performance: {
    maxAssetSize: 250000,       // 250KB por asset
    maxEntrypointSize: 300000,  // 300KB por entrypoint
    hints: 'error'              // Falhar o build se exceder
  }
};

Com hints: 'error', o build falha se algum asset exceder o limite definido. Pode parecer agressivo, mas é exatamente isso que impede que alguém faça merge de um PR que adicione uma dependência de 500KB sem que ninguém repare.

Integrar no CI/CD

Ferramentas como o bundlesize ou o size-limit permitem definir budgets no package.json e verificá-los automaticamente em cada PR:

// package.json
{
  "size-limit": [
    {
      "path": "dist/main.*.js",
      "limit": "150 KB",
      "gzip": true
    },
    {
      "path": "dist/vendor.*.js",
      "limit": "100 KB",
      "gzip": true
    }
  ]
}
# .github/workflows/bundle-check.yml
name: Bundle Size Check
on: [pull_request]
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx size-limit

Com isto, cada PR que exceda o budget é automaticamente bloqueado. Na minha experiência, é a forma mais eficaz de manter o bundle sob controlo a longo prazo — muito mais do que depender da boa vontade de quem faz code review.

Checklist de Otimização — Por Ordem de Impacto

Para facilitar, aqui está a sequência que recomendo, ordenada por impacto decrescente:

  1. Analisar o bundle — correr o Bundle Analyzer e a Coverage tab para identificar os maiores culpados
  2. Code splitting por rotas — implementar lazy loading em todas as rotas (maior ganho imediato, quase sempre)
  3. Eliminar dependências pesadas — substituir moment por dayjs, lodash completo por imports seletivos
  4. Ativar tree shaking — verificar que sideEffects está configurado e que os imports são seletivos
  5. Dynamic imports para componentes pesados — modais, editores, gráficos, mapas
  6. Ativar compressão Brotli — no servidor ou pré-comprimido durante o build
  7. Considerar React Server Components — para páginas com lógica pesada que não precisa de interatividade
  8. Definir performance budgets — integrar no CI/CD para prevenir regressões
  9. Quebrar long tasks — usar scheduler.yield() em operações pesadas na main thread
  10. Monitorizar continuamente — Lighthouse CI, Web Vitals library, alertas em dashboards

Não precisas de fazer tudo de uma vez. Começa pelo topo da lista e avança conforme tiveres tempo. Mesmo implementar apenas os primeiros dois ou três pontos já faz uma diferença enorme.

Perguntas Frequentes (FAQ)

Qual é o tamanho ideal de um bundle JavaScript?

Não existe um número mágico, mas como referência: o bundle inicial (o que carrega na primeira visita) deve estar abaixo de 150-200KB comprimido (gzip) para a maioria dos sites. Para aplicações mais complexas como dashboards e SPAs, até 300KB comprimido é aceitável — desde que o code splitting esteja bem implementado. O Google recomenda que o JavaScript total não faça a main thread bloquear por mais de 3,5 segundos em dispositivos de gama média.

Code splitting piora a performance por causa dos requests extra?

Com HTTP/2 (ou HTTP/3), múltiplos requests em paralelo têm custo mínimo — o overhead de conexão é praticamente eliminado pelo multiplexing. O benefício de carregar apenas o código necessário supera largamente o custo dos requests adicionais. Dito isto, evita fragmentar demais — chunks com menos de 10KB provavelmente não justificam o overhead de um request separado.

Tree shaking funciona com todas as bibliotecas npm?

Infelizmente, não. O tree shaking só funciona com módulos que usam a sintaxe ES modules (import/export). Bibliotecas que ainda usam CommonJS (module.exports/require) não podem ser tree-shaked. Antes de instalar uma dependência, verifica no Bundlephobia se tem o ícone de tree shaking. Em 2026, a maioria das bibliotecas populares já suporta ESM, mas ainda há exceções irritantes.

Como medir o impacto real das otimizações de bundle no utilizador?

Usa a biblioteca web-vitals do Google para capturar métricas de utilizadores reais (RUM — Real User Monitoring) e enviá-las para o teu sistema de analytics. As métricas a monitorizar: INP (impactado por JavaScript execution time), LCP (impactado pelo tamanho do bundle inicial) e TBT (Total Blocking Time, correlacionado com long tasks). Compara antes e depois de cada otimização usando o relatório de Core Web Vitals do Google Search Console.

Devo usar React Server Components ou continuar com SSR tradicional?

Se já usas Next.js 13+ com o App Router, os Server Components já são o padrão — não precisas de fazer nada especial para os usar. Para projetos existentes com o Pages Router, a migração pode ser gradual. O benefício é mais significativo em páginas com muita lógica de servidor (data fetching, formatação, cálculos) e pouca interatividade no browser. Para SPAs altamente interativas como dashboards e editores, o ganho é menor porque a maioria do código precisa de estar no cliente de qualquer forma.

Sobre o Autor Editorial Team

Our team of expert writers and editors.