Pular para o conteúdo
~/.primo-academy.sh
☰ Aulas · Performance Web: Core Web Vitals, bundle, imagens, CDN e CI · 0/7
Recomendado: essencial

Bundle analysis: code splitting, tree shaking, dynamic imports

4 min de leitura

fonte

Voce viu o Critical Rendering Path e' importante pra LCP. Agora o outro lado da performance: INP (responsividade) depende quase totalmente de quanto JS o browser tem que parsear, compilar e executar. Cada KB de JS e' ~1ms de parse em celular mid-range. Um bundle de 2MB em 3G significa 10s de tela branca + travamento ao interagir.

A boa noticia: Vite (Rollup por baixo) faz tree shaking automatico. A noticia ruim: sem code splitting, todo seu app vira 1 unico arquivo de 2MB carregado na primeira visita - mesmo paginas que usam 5% do codigo.

Se voce entende o que e' code splitting, tree shaking, dynamic imports, e como ler o bundle visualizer pra encontrar os "vilões" de tamanho, voce sai de "bundle de 2MB" pra "initial bundle de 80KB + 10 lazy chunks de 50KB cada, carregados sob demanda".

O essencial 🟢

O que e' "bundle" e por que importa. O bundle e' o arquivo final de JavaScript que o browser baixa e executa. Vite/Webpack/ Rollup pega seu codigo (que pode ter centenas de arquivos .ts/.tsx/.js), processa, e gera 1 ou mais arquivos finais (chamados "chunks") prontos pro browser.

A questao nao e' "qual o tamanho total do codigo", mas "quanto o usuario baixa antes de poder interagir". Se seu app tem 2MB total mas so 100KB sao necessarios pra primeira interacao, o usuario so baixa 100KB. Isso e' code splitting.

Bundle inicial vs lazy chunks. A divisao fundamental:

  • Initial bundle (synchronous): tudo que baixa no load inicial da pagina. Inclui o "shell" do app (React, router, layout principal, componentes da rota atual). Esse tamanho importa pro TTI/LCP.
  • Lazy chunks (async): modulos que baixam sob demanda, quando o usuario navega ate eles. Cada pagina pode ser um chunk. Esses tamanhos importam pra INP quando o usuario interage.

A regra: o initial bundle deve ser o menor possivel. Tudo que pode esperar deve ser lazy.

Code splitting: o que e' e por que funciona. Em vez de "1 arquivo com tudo", voce tem "varios arquivos pequenos". O browser baixa o initial bundle (rapido), renderiza, e vai baixando os lazy chunks conforme o usuario navega. Mesma UX, mas 10x mais rapido pra primeira interacao.

Em Vite/Rollup, code splitting vem de 3 fontes:

  1. import() dinamico (lazy loading manual).
  2. Roteamento (cada pagina vira um chunk automatico no React Router / Next.js).
  3. SplitChunksPlugin (heuristica automatica de Vite/Rollup pra separar vendor de app code).

import() dinamico - o pattern principal. Em vez de import estatico:

// ESTATICO: tudo no initial bundle
import { HeavyChart } from "./charts/HeavyChart";
import { Markdown } from "./Markdown";

function Dashboard() {
  return (
    <div>
      <HeavyChart />     {/* 200KB - mas usuario pode nem chegar aqui */}
      <Markdown />        {/* 80KB */}
    </div>
  );
}

Voce usa import() dinamico + lazy() do React:

// DINAMICO: lazy chunks separados
import { lazy, Suspense } from "react";

const HeavyChart = lazy(() =>
  import("./charts/HeavyChart").then((m) => ({ default: m.HeavyChart }))
);
const Markdown = lazy(() =>
  import("./Markdown").then((m) => ({ default: m.Markdown }))
);

function Dashboard() {
  return (
    <div>
      <Suspense fallback={<div>Carregando grafico...</div>}>
        <HeavyChart />
      </Suspense>
      <Suspense fallback={<div>Carregando texto...</div>}>
        <Markdown />
      </Suspense>
    </div>
  );
}

O bundle inicial nao inclui HeavyChart ou Markdown. Quando o componente vai renderizar pela primeira vez, o React baixa o chunk correspondente e renderiza. O <Suspense> mostra o fallback durante o download.

Em Vite, cada import() dinamico vira um chunk separado no dist/assets/. O nome fica com hash (ex: HeavyChart-abc123.js) pra cache busting.

Tree shaking - remover codigo nao usado. Vite/Rollup faz tree shaking automaticamente no build. A logica: se voce tem export function a() {} e export function b() {} em utils.ts, mas so importa a, o bundle final so inclui a.

Pra tree shaking funcionar 100%, o codigo precisa ser ES modules (import/ export). CommonJS (require/module.exports) nao da pra fazer tree shaking estatico.

// utils.ts
export function a() { return 1; }
export function b() { return 2; }
export function c() { return 3; }
// app.ts
import { a } from "./utils";  // so importa 'a'
// bundle final: so a() - b() e c() sao removidos

Side effects quebram tree shaking. Se um modulo tem "side effects" (executa codigo so por ser importado), o bundler preserva o modulo inteiro, mesmo que nenhum export seja usado:

// logger.ts
console.log("Logger inicializado!");  // side effect
export function log() { /* ... */ }
// app.ts
import "./logger";  // bundle inclui logger INTEIRO (por causa do console.log)

Pra tree shaking funcionar, marque o modulo como side-effect-free no package.json:

{
  "sideEffects": false
}

Ou liste os arquivos que tem side effects explicitamente:

{
  "sideEffects": ["*.css", "./src/polyfills.ts"]
}

Bundle visualizer - encontrar os "vilões". A melhor ferramenta pra debugar bundle size e' o rollup-plugin-visualizer (ou vite-bundle-visualizer). Gera um treemap interativo mostrando cada modulo e seu tamanho:

pnpm add -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from "rollup-plugin-visualizer";

export default {
  plugins: [
    visualizer({
      filename: "dist/stats.html",
      gzipSize: true,    // mostra tamanho gzipped tambem
      brotliSize: true,
    }),
  ],
};

Apos pnpm build, abre dist/stats.html no browser. Voce ve um treemap onde o tamanho de cada quadrado = espaco no bundle final. O maior quadrado e' o seu vilao. Em 90% dos apps:

  • moment.js (300KB) - substitua por date-fns ou day.js.
  • lodash full (70KB) - importe so lodash-es (ES modules, tree-shakable) ou pacotes especificos (lodash.debounce).
  • chart.js full (200KB) - lazy load (quase sempre nao precisa no initial).
  • @mui/material ou antd (500KB+) - se so usa 5 componentes, vale custom build ou substituir por algo mais leve.

Bundle budgets - o tamanho "certo". Nao existe numero magico, mas heuristicas 2026:

  • Initial JS (gzip): < 100KB ideal, < 200KB aceitavel. Mais que isso, mobile em 3G ja sofre.
  • Initial CSS (gzip): < 30KB ideal.
  • Lazy chunks (gzip): < 50KB cada. Chunks de 200KB quebram a UX (usuario clica e espera 2s).

Pra cada KB de JS no initial bundle, +1ms de parse em celular mid-range. 100KB = 100ms de parse. 500KB = 500ms de parse (so isso, sem contar execucao). Em celular de entrada (Moto G, Redmi barato), parse e' 3x mais lento que no seu MacBook.

Differential serving - bundle diferente por browser. Em 2026, ~95% dos usuarios suportam features modernas. Mas e os 5%? Voce pode gerar 2 bundles: um com tudo moderno (ES2020+, sem polyfills) e um "legacy" (ES5, polyfills incluidos). O moderno e' 30-40% menor.

Em Vite:

// vite.config.ts
export default {
  build: {
    target: "es2020",  // bundle moderno
  },
};

E usa <script type="module"> pra forcar browsers modernos a pegar o bundle novo. Browsers antigos ignoram o type="module" e caem no <script nomodule> com polyfills.

Vendor splitting - separar seu codigo de deps. Vite faz isso automaticamente com build.rollupOptions.output.manualChunks. Util pra cache de longo prazo: vendor raramente muda, app code muda em cada PR. Usuario so re-baixa o que mudou.

Aprofundamento 🟡

<link rel="modulepreload"> - preload de module chunks. Em vez de preload de "image" ou "style", use modulepreload pra avisar o browser sobre chunks de JS que vao ser necessarios em breve:

<head>
  <link rel="modulepreload" href="/assets/app.js">
  <link rel="modulepreload" href="/assets/HomePage.js">
</head>

O browser baixa esses chunks em paralelo com o HTML, antes mesmo do React decidir o que renderizar. Util pra rota provavel (landing page apos login, dashboard, etc).

React.lazy + Suspense em rotas. O pattern mais comum de code splitting: cada rota vira um chunk:

import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";

const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Acessa / - so baixa Home.js. Acessa /dashboard - baixa Dashboard.js separadamente. Initial bundle nao inclui nenhuma das paginas "filhas".

Named chunks vs default chunks. Por default, Rollup nomeia chunks com numeros (chunk-abc123.js). Pra debugar, use output.chunkFileNames: "[name].js" - o chunk vai ter o nome do modulo (HomePage.js, Dashboard.js):

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: "assets/[name]-[hash].js",
        entryFileNames: "assets/[name]-[hash].js",
      },
    },
  },
};

Em prod, mantenha o hash (cache busting). Em dev/staging, remova o hash pra debugar.

Prefetch de chunks "provavelmente" necessarios. Em SPA, o usuario provavelmente vai navegar de / pra /produtos. Voce pode prefetch o chunk de produtos enquanto o usuario ta na home:

// Em Home.tsx
useEffect(() => {
  // baixa em idle time
  const id = requestIdleCallback(() => {
    import("./pages/Products");
  });
  return () => cancelIdleCallback(id);
}, []);

Quando o usuario clicar em "produtos", o chunk ja ta em cache - navega instantaneo. O requestIdleCallback garante que o prefetch nao concorre com o trabalho critico do browser.

webpack-bundle-analyzer equivalente Rollup. Se voce usa Webpack (menos comum em 2026 com Vite), a ferramenta equivalente e' webpack-bundle-analyzer. Mesma logica, treemap visual. Vale a pena rodar 1x em todo projeto - surpresas comuns:

  • core-js (polyfills) sendo importado inteiro em vez de especifico.
  • react sendo duplicado (2 versoes).
  • moment + moment-timezone (300KB+ facil).

Server-side rendering resolve bundle size? Nao diretamente. SSR renderiza o HTML no servidor, mas o JS ainda precisa hidratar o client. Em Next.js, mesmo com SSR, o initial JS do app e' 100-200KB (React + Next runtime). SSR ajuda LCP (HTML chega rapido), nao INP (JS ainda precisa executar). Cobre em nextjs.

Pra quem quer ir mais alem 🔴

Por que import() dinamico nao funciona com SSR. Em SSR, o servidor nao tem "navegacao do usuario" - tudo e' renderizado de uma vez. O import() dinamico funciona, mas o chunk e' incluido no HTML inicial (via <script> inline com o codigo do chunk). Em client-side, o chunk e' baixado sob demanda. A diferenca: SSR prioriza First Paint rapido, nao Tamanho do JS. Pra "bundle lazy em SSR", voce precisa de streaming SSR (Next.js 13+ App Router faz). Cobre em nextjs.

esbuild vs swc vs terser - qual minifier usar. Vite usa esbuild por default (rapido, 100x mais rapido que terser, mas minificacao um pouco menos agressiva). Pra prod maximo, troque por terser ou swc (Rollup aceita custom minifier via build.minify: "terser"). Em 2026, a diferenca de tamanho e' ~3-5%. Setup complexo pra pouco ganho - deixe o default de Vite.

Module Federation e micro-frontends. Pattern avancado onde apps separados sao "combinados" em runtime via Webpack 5 Module Federation. Util pra times grandes com apps separados, mas adiciona 50-200KB de overhead. Pra maioria dos apps, NAO vale a complexidade. Micro-frontends exigem Module Federation; times normais exigem Next.js + pages.

size-limit - budget no package.json. Configura budget por "entry point" e quebra o build se exceder:

{
  "size-limit": [
    {
      "path": "dist/assets/index-*.js",
      "limit": "100 KB",
      "gzip": true
    }
  ]
}

CI quebra se o initial bundle passar de 100KB gzipped. Combina com Lighthouse CI (coberto em perf-em-ci).

Importmap e bare specifier resolution. Pattern emergente (Chrome 89+, Firefox 108+, Safari 16.4+) que destrava controle fino de como imports bare (import React from "react") sao resolvidos. Em 2026, ainda limitado mas promissor pra Deno e edge runtimes.

Leitura recomendada:

Dica: o erro mais comum em bundle analysis e' focar no total em vez do initial. "Meu bundle tem 1.5MB" - mas se so 80KB sao initial (resto e' lazy), o usuario ta bem. O numero que importa e' o initial bundle size gzipped. Sempre meca o initial, nao o total.

No proximo no, vamos imagens e fontes: os 2 tipos de recurso que mais afetam LCP e CLS. AVIF vs WebP, <picture> com srcset/sizes, font-display: swap, e o pattern de preloading pra LCP.

// Quiz

Por que 'bundle total de 1.5MB' nao e' o numero que importa pra performance?

Escolha uma alternativa

// recursos

// avaliação da trilha

—
ainda sem avaliações