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 PDFUPNG(200 KB) — trazido transitivamentemarked,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 usoUPNG-*.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:
- Resolver
fonts.googleapis.com(DNS, TLS) - Baixar o arquivo CSS (uma bagunça de blocos
@font-facecheia de redirects) - Resolver
fonts.gstatic.com(DNS, TLS de novo, origin diferente!) - Baixar o arquivo
.woff2real
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
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.
React.lazy()maisSuspenseé a vitória de performance mais barata. Splitting por rota toma dez minutos pra implementar e costuma economizar 50-90% do seu bundle principal.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.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.
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.
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.
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.
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:
- Compressão de imagens (sim, incluindo seu logo)
- Ferramentas PDF (as que dispararam o insight de lazy-loading)
- Compressão de vídeo (FFmpeg.wasm, também lazy)
- OCR (Tesseract.js, também lazy)
- O blog completo com mais notas de engenharia
Todos eles carregam bem abaixo de um segundo numa conexão normal. Agora.