← Todos os posts

Como reduzimos o bundle principal de um app React com 100 ferramentas de 6 MB para 307 KB

2026-05-25

A saída do build do Vite vinha dizendo isso há meses:

(!) Some chunks are larger than 500 kB after minification.

A gente ignorou. O site funcionava bem em banda larga. A IA de remoção de fundo carregava um arquivo WebAssembly de 30 MB quando invocada — com certeza um bundle JS de 6 MB era pequeno em comparação.

Não era.

Um amigo visitou o toolkoala.com no Pixel dele com WiFi de hotel instável. O Largest Contentful Paint demorou 2,5 segundos só no render delay do elemento — sem contar a rede. A auditoria Lighthouse continuava nos descontando por "render-blocking resources" e "reduce unused JavaScript". O mais grave: o Google Search Console tinha começado a marcar o site nos relatórios de Core Web Vitals.

Então sentamos pra ler a saída do build de verdade.

dist/assets/index-CdRXzQJL.js  5,998 kB │ gzip: 1,755 kB

Seis. Megabytes. De JavaScript. Entregues a cada visitante da página inicial, antes de clicarem em qualquer coisa.

Este post é o log de engenharia de chegar a 307 KB raw / 92 KB gzipped — redução de 95% — e o LCP de ~2,5s pra menos de 1,5s. A nota da auditoria foi de F pra A. O site em si não mudou.

O que tinha nesses 6 MB

A primeira coisa útil foi rodar npx vite-bundle-visualizer (ou só olhar a saída do build ordenada por tamanho). Os maiores responsáveis:

  • Todos os 70+ componentes de ferramenta (RemoveBackground, PdfMerge, LoremIpsum, …)
  • Os 8 arquivos JSON de i18n (inglês + 7 traduções, ~300 KB cada)
  • pdf-lib + pdfjs-dist (combinados ~500 KB) — usados por 30 ferramentas PDF
  • UPNG (200 KB) — trazido transitivamente
  • marked, diff, jszip, pptxgen, xlsx, mammoth — cada um 100-500 KB

A maioria pertencia a ferramentas que o usuário ainda nem estava usando. Estavam sendo baixadas só caso o usuário clicasse no merger de PDF depois de olhar o gerador de lorem ipsum.

Esse é o padrão React default, e o Vite te deixa fazer isso de boa:

import RemoveBackground from './tools/RemoveBackground'
import PdfMerge from './tools/PdfMerge'
// … mais 65 linhas

<Routes>
  <Route path="/remove-background" element={<RemoveBackground />} />
  <Route path="/pdf-merge" element={<PdfMerge />} />
  // …
</Routes>

Cada instrução import diz "entregue esse código em cada página". É assim que 70 ferramentas viram um bundle de 6 MB.

Correção 1: Code splitting por rota (economiza ~5,7 MB)

A correção React padrão pra isso é React.lazy() mais Suspense. O bundler do Vite (rollup já configurado) emite automaticamente um chunk por import dinâmico.

Convertemos cada import de ferramenta:

const RemoveBackground = lazy(() => import('./tools/RemoveBackground'))
const PdfMerge = lazy(() => import('./tools/PdfMerge'))
// …

E envolvemos as rotas:

<Suspense fallback={<div className="loading">Loading…</div>}>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/remove-background" element={<RemoveBackground />} />
    // …
  </Routes>
</Suspense>

Deixamos Home e Nav com import estático. A home é o landing mais comum, e a nav precisa renderizar imediatamente em toda página.

Só com essa mudança: bundle principal caiu de 5.998 KB → 3.025 KB. Cada ferramenta virou seu próprio chunk: 3-50 KB cada, baixado sob demanda quando o usuário navega.

O trade-off: visitar uma ferramenta pela primeira vez agora exige uma viagem de rede extra pra pegar o chunk dela. Mas os chunks são pequenos (frequentemente um dígito em KB gzipped), e o HTTP/2 moderno amortiza o custo da viagem entre várias requisições paralelas. Em conexões reais, o aumento de latência é invisível.

A vitória é na primeira carga — a que importa pro Largest Contentful Paint e pro bounce do primeiro visitante. Tínhamos cortado isso pela metade.

Correção 2: Lazy-load de locales com import.meta.glob (economiza ~2 MB)

O próximo grande pedaço do bundle era a internacionalização. Suportamos 8 idiomas, cada um com um JSON de ~300 KB contendo traduções de títulos, FAQs e descrições de feature das 100+ ferramentas. A config original era a coisa óbvia:

import en from './locales/en.json'
import zhCN from './locales/zh-CN.json'
import zhTW from './locales/zh-TW.json'
import ja from './locales/ja.json'
// … mais 4

i18n.init({
  resources: {
    en: { translation: en },
    'zh-CN': { translation: zhCN },
    // …
  }
})

Os 8 arquivos (~2,5 MB raw, ~700 KB gzip) inlinados no bundle principal. Um falante de francês visitando nossa home em inglês baixava as traduções coreanas.

O Vite tem uma primitiva lindona chamada import.meta.glob que conserta isso em umas 10 linhas:

const localeLoaders = import.meta.glob('./locales/*.json')

const initial = detectLangFromPath() || 'en'
const [enData, activeData] = await Promise.all([
  localeLoaders['./locales/en.json'](),
  initial !== 'en' ? localeLoaders[`./locales/${initial}.json`]() : null,
])

i18n.init({
  resources: {
    en: { translation: enData.default },
    [initial]: { translation: activeData?.default },
  },
  lng: initial,
})

O Vite vê cada chamada import('./locales/<x>.json') e emite um chunk separado por locale. No startup só carrega o idioma atual do usuário (mais inglês como fallback).

O fallback é pequeno (inglês é carregado ansiosamente pra que traduções faltantes em outros idiomas não quebrem). Quando o usuário troca de idioma manualmente, fazemos patch do i18n.changeLanguage pra buscar o novo locale sob demanda:

const origChangeLanguage = i18n.changeLanguage.bind(i18n)
i18n.changeLanguage = async (lng) => {
  if (!i18n.hasResourceBundle(lng, 'translation')) {
    const data = await loadLocale(lng)
    i18n.addResourceBundle(lng, 'translation', data, true, true)
  }
  return origChangeLanguage(lng)
}

Um gotcha: isso torna a inicialização do i18n assíncrona, então o React não pode renderizar antes do locale resolver. Usamos o await top-level que o Vite suporta em ESM:

// main.jsx
import { initI18n } from './i18n'
await initI18n()
createRoot(...).render(<App />)

Top-level await é suportado em navegadores modernos (Chrome 89+, Safari 15+, Firefox 89+).

Depois desse fix: bundle principal 3.025 KB → 307 KB (gzip 1.755 KB → 92 KB). Tínhamos descarregado ~2,7 MB de dados de locale do caminho crítico.

Correção 3: O vazamento secundário — PdfToolShell (economiza ~600 KB)

Depois das duas primeiras correções, o PageSpeed Insights ainda marcava "Reduce unused JavaScript" com culpados específicos:

  • pdf-*.js — 116 KB, 96% sem uso
  • UPNG-*.js — 118 KB, 28% sem uso

Por que bibliotecas PDF estavam carregando na home?

Tínhamos um componente compartilhado PdfToolShell que embrulha 30+ rotas de ferramentas PDF (PDF merge, split, rotate, etc.). Ele não é uma ferramenta em si, é um shell reutilizável. Estava importado estaticamente no App.jsx:

import PdfToolShell from './components/PdfToolShell'

E PdfToolShell faz isso no topo:

import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'

Então a stack PDF inteira estava sendo iça pro bundle principal — mesmo se o usuário tivesse visitando /lorem-ipsum.

A correção foi o mesmo tratamento lazy():

const PdfToolShell = lazy(() => import('./components/PdfToolShell'))

A lição: lazy-loadear componentes de ferramenta não basta se um componente "shell" compartilhado ainda importa estaticamente suas dependências pesadas. Audite cada import estático pelo que ele puxa transitivamente.

Correção 4: Logo (economiza 640 KB)

Essa é constrangedora. O logo do site era um PNG 1024×1024, 640 KB. Era exibido a 32×32 pixels na navbar. Ninguém tinha questionado isso.

A correção levou menos tempo do que ler esse parágrafo: soltamos logo.png no nosso próprio Compressor de Imagens. O slider já estava em 80% de qualidade (default). Apertamos Download.

Resultado: 6,9 KB. Redução de 99% sem diferença visível a 32×32 — nem a 256×256, onde usamos o mesmo logo pro card social do Open Graph.

Pro caso do social card (preview do Twitter, Facebook, LinkedIn), queríamos que o logo ficasse nítido escalonado pra ~256 px. Pegamos o 1024×1024 original, soltamos no nosso Redimensionar Imagem, setamos 256×256, passamos pelo Compressão de Imagem a 90% de qualidade e obtivemos logo-256.png a 64 KB. A meta tag og:image aponta pra isso agora.

Duas ferramentas, dois minutos, 633 KB economizados em cada carga de página.

Se você está lendo isso no celular e o logo acima tá legal: essa é a imagem de 6,9 KB. A versão de 640 KB ficava idêntica.

Se você tem um logo maior que 50 KB no seu próprio site, o compressor de imagens do ToolKoala tá ali. Solta, vê o preview antes/depois, baixa o menor. Nada faz upload — seu logo não vai pro nosso servidor, é processado no seu navegador. Esse é o ponto inteiro do projeto, incluindo a parte em que usamos isso na gente mesmo.

Correção 5: Matando a dependência do Google Fonts (economiza ~1,2 s de delay LCP)

Nosso <head> parecia o de toda web app que você já viu:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">

O PageSpeed reportou 1.258 ms de latência de caminho crítico por causa disso. O navegador tinha que:

  1. Resolver fonts.googleapis.com (DNS, TLS)
  2. Baixar o arquivo CSS (uma bagunça de blocos @font-face cheia de redirects)
  3. Resolver fonts.gstatic.com (DNS, TLS de novo, origin diferente!)
  4. Baixar o arquivo .woff2 real

Pra um site utilitário, Inter é bonito mas não é necessário. Já tínhamos um fallback CSS perfeitamente bom:

font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
             system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;

No macOS isso resolve pra San Francisco (font do sistema da Apple). No Windows, Segoe UI. No Android, Roboto. As três são fontes excelentes que vêm pré-instaladas no sistema operacional. Usam zero largura de banda e zero tempo de carregamento.

Deletamos o link do Google Fonts. O site se parece ~95% igual — diferente em Linux sem um bom sans-serif instalado, mas essa é uma audiência pequena e os fallbacks degradam graciosamente.

A "latência de caminho crítico" do PageSpeed caiu 1,2 segundos.

Correção 6: Inlinar o CSS do bundle (economiza 160 ms)

O recurso render-blocking que restava era o bundle CSS que o Vite emitia:

<link rel="stylesheet" href="/assets/index-CcF97ram.css">

5,4 KB. Tomava ~160 ms de round-trip numa conexão típica — bloqueando o primeiro paint.

Pra um bundle CSS desse tamanho, inliná-lo no HTML ganha do cache. O custo de inlinar é que cada página HTML agora carrega os 5,4 KB. O benefício é uma requisição bloqueante a menos na primeira carga.

Nosso script de build (que pós-processa a saída do Vite pro SSR) agora lê o arquivo CSS e substitui por um bloco <style>:

const cssMatch = baseHtml.match(/<link\s+rel="stylesheet"[^>]*href="([^"]+)"[^>]*>/)
if (cssMatch) {
  const cssPath = join(DIST, cssMatch[1].replace(/^\//, ''))
  const css = readFileSync(cssPath, 'utf8')
  baseHtml = baseHtml.replace(cssMatch[0], `<style>${css}</style>`)
}

Nota: isso é OK pra nossa stylesheet de 5 KB. Se você tem um bundle CSS de 100 KB, inlinar derrota o cache entre páginas; deixa externo (ou extrai só o critical CSS).

O que deixamos deliberadamente sem corrigir

Google AdSense e Google Analytics gtag. Ambos são scripts async que o PageSpeed ainda marca por forced reflow e tamanho de transferência alto. Eles são 500+ ms de latência de caminho crítico entre os dois.

Mas AdSense é como o site é financiado. Remover não é um trade-off de engenharia; é um de negócio. Mesma coisa pra Analytics — sem ele estaríamos voando às cegas sobre quais ferramentas são populares e quais bugs ninguém tá batendo.

Em princípio poderíamos diferir AdSense pra carregar depois do elemento LCP ter sido pintado. Tentamos isso por meia hora; requeria sobrescrever a própria lógica de injeção do AdSense e produzia flicker visual enquanto os anúncios carregavam pós-paint. Não vale a complexidade de engenharia por ~200 ms de economia.

Se você tá otimizando um site sem anúncios, você tem mais opções que a gente. Tivemos que aceitar isso.

Os resultados

O primeiro paint com cache fria pra https://www.toolkoala.com/ do nosso benchmark básico:

Métrica Antes Depois Mudança
Bundle principal (raw) 5.998 KB 307 KB -95%
Bundle principal (gzip) 1.755 KB 92 KB -95%
Logo PNG 640 KB 7 KB -99%
Total JS na home ~3 MB 544 KB -82%
CSS render-blocking 160 ms 0 ms (inlined) -100%
Bloqueio Google Fonts 1.258 ms 0 ms -100%
FCP ~2,4 s 256 ms -89%
LCP / DOM Complete ~2,5 s 1.295 ms -48%
Nota Lighthouse Performance D A (6/6 budget)

O site em si não mudou — mesmos componentes React, mesma UI, mesmo backend. Toda correção foi uma mudança de configuração ou de processo de build.

Pegadas se você tá distribuindo um app React + Vite

  1. Olhe a saída do bundle a cada release. São duas linhas no seu log de CI. Se algo passa de 500 KB, investiga. O Vite te conta de graça.

  2. React.lazy() mais Suspense é a vitória de performance mais barata. Splitting por rota toma dez minutos pra implementar e costuma economizar 50-90% do seu bundle principal.

  3. import.meta.glob é o superpoder do Vite pra lazy-loadear "vários arquivos do mesmo tipo" — locales, ícones, posts de blog, o que for. Use no lugar de listas longas de imports estáticos.

  4. Audite imports transitivos. Um componente "shell" compartilhado que importa estaticamente libs pesadas vai derrotar todo o seu lazy-loading por rota. O bug do PdfToolShell nos custou um chunk de 600 KB em toda página até percebermos.

  5. Seu logo provavelmente é grande demais. Logos na maioria dos sites são exibidos a ≤64 px. Deveriam ser ≤10 KB. Use a ferramenta de resize neste mesmo site se você não tem ImageMagick instalado.

  6. Questione cada carga de fonte externa. Fontes de sistema no macOS, Windows e Android são todas excelentes. Se sua marca não depende de um typeface específico (e a maioria não depende), deleta o link do Google Fonts inteiro.

  7. Inline CSS pequeno. Abaixo de ~10 KB, a latência economizada pulando uma requisição supera o benefício de cache de um arquivo externo.

  8. AdSense e Analytics estão majoritariamente fora do seu controle. Aceita e otimiza o que dá pra otimizar.

Isso foi cerca de seis horas de trabalho de engenharia no total, espalhadas por dois dias. As maiores melhorias (route splitting + locale splitting) tomaram cerca de duas horas cada. As quatro horas restantes foram medição, testes de regressão e escrever esse post.

Se você achou isso útil, as ferramentas que saíram do build mais limpo ainda estão aqui, ainda grátis, ainda rodando inteiramente no seu navegador:

Todos eles carregam bem abaixo de um segundo numa conexão normal. Agora.