Cómo redujimos el bundle principal de una app React con 100 herramientas de 6 MB a 307 KB
2026-05-25
La salida del build de Vite llevaba meses diciéndonos esto:
(!) Some chunks are larger than 500 kB after minification.
Lo habíamos ignorado. La web funcionaba bien por banda ancha. La IA de quitar fondos cargaba un archivo WebAssembly de 30 MB cuando se invocaba — un bundle JS de 6 MB seguro era pequeño en comparación.
No lo era.
Un amigo visitó toolkoala.com en su Pixel con WiFi de hotel inestable. El Largest Contentful Paint tardó 2,5 segundos solo en el render delay del elemento — sin contar la red. La auditoría Lighthouse seguía penalizándonos por "render-blocking resources" y "reduce unused JavaScript". Lo más grave: Google Search Console había empezado a marcar la web en los informes de Core Web Vitals.
Así que nos sentamos a leer la salida del build de verdad.
dist/assets/index-CdRXzQJL.js 5,998 kB │ gzip: 1,755 kB
Seis. Megabytes. De JavaScript. Enviados a cada visitante de la página principal, antes de que pinchara en nada.
Este post es el log de ingeniería para dejarlo en 307 KB raw / 92 KB gzipped — una reducción del 95% — y bajar el LCP de ~2,5s a menos de 1,5s. La nota pasó de F a A. La web en sí no cambió.
Qué había en esos 6 MB
Lo primero útil fue ejecutar npx vite-bundle-visualizer (o simplemente mirar la salida del build por tamaño). Los mayores culpables:
- Los 70+ componentes de herramientas (
RemoveBackground,PdfMerge,LoremIpsum, …) - Los 8 archivos JSON de i18n (inglés + 7 traducciones, ~300 KB cada uno)
pdf-lib+pdfjs-dist(combinados ~500 KB) — usados por 30 herramientas PDFUPNG(200 KB) — incluido transitivamentemarked,diff,jszip,pptxgen,xlsx,mammoth— cada uno 100-500 KB
La mayoría pertenecían a herramientas que el usuario aún no estaba usando. Se descargaban por si el usuario clicaba en el mergeador de PDF tras mirar el generador de lorem ipsum.
Es el patrón React por defecto, y Vite te deja hacerlo encantado:
import RemoveBackground from './tools/RemoveBackground'
import PdfMerge from './tools/PdfMerge'
// … 65 líneas más
<Routes>
<Route path="/remove-background" element={<RemoveBackground />} />
<Route path="/pdf-merge" element={<PdfMerge />} />
// …
</Routes>
Cada import dice "envía este código en cada página". Así es como 70 herramientas se convierten en un bundle de 6 MB.
Fix 1: Code splitting a nivel de ruta (ahorra ~5,7 MB)
El fix estándar de React para esto es React.lazy() con Suspense. El bundler de Vite (rollup ya configurado) emite automáticamente un chunk por import dinámico.
Convertimos cada import de herramienta:
const RemoveBackground = lazy(() => import('./tools/RemoveBackground'))
const PdfMerge = lazy(() => import('./tools/PdfMerge'))
// …
Y envolvimos las rutas:
<Suspense fallback={<div className="loading">Loading…</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/remove-background" element={<RemoveBackground />} />
// …
</Routes>
</Suspense>
Dejamos Home y Nav con import estático. La home es el landing más común, y la nav tiene que renderizarse inmediatamente en cada página.
Tras este solo cambio: bundle principal bajó de 5.998 KB → 3.025 KB. Cada herramienta pasó a ser su propio chunk: 3-50 KB cada uno, descargado a demanda cuando el usuario navega.
El trade-off: visitar una herramienta por primera vez requiere ahora una ida y vuelta de red extra para obtener su chunk. Pero los chunks son pequeños (a menudo de un dígito en KB gzipped), y HTTP/2 amortigua el coste de la ida y vuelta entre múltiples peticiones paralelas. En conexiones reales, el aumento de latencia es invisible.
La victoria está en la primera carga — la que importa para Largest Contentful Paint y para el rebote del primer visitante. Habíamos cortado eso a la mitad.
Fix 2: Lazy-load de locales con import.meta.glob (ahorra ~2 MB)
El siguiente gran trozo era la internacionalización. Soportamos 8 idiomas, cada uno con un JSON de ~300 KB con traducciones de títulos, FAQs y descripciones de las 100+ herramientas. La configuración original era lo obvio:
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'
// … 4 más
i18n.init({
resources: {
en: { translation: en },
'zh-CN': { translation: zhCN },
// …
}
})
Los 8 archivos (~2,5 MB raw, ~700 KB gzip) inlinados en el bundle principal. Un hispanohablante visitando nuestra home en inglés se bajaba las traducciones coreanas.
Vite tiene una primitiva preciosa, import.meta.glob, que arregla esto en unas 10 líneas:
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,
})
Vite ve cada llamada import('./locales/<x>.json') y emite un chunk separado por locale. Al arrancar solo se carga el idioma actual (más el inglés como fallback).
El fallback es pequeño (inglés se carga ansiosamente para que las traducciones faltantes no rompan). Cuando el usuario cambia idioma manualmente, parcheamos i18n.changeLanguage para traer el locale a demanda.
Un gotcha: esto hace que la inicialización de i18n sea asíncrona, así que React no puede renderizar hasta que el locale resuelva. Usamos el await top-level que Vite soporta en ESM:
// main.jsx
import { initI18n } from './i18n'
await initI18n()
createRoot(...).render(<App />)
Top-level await está soportado en navegadores modernos (Chrome 89+, Safari 15+, Firefox 89+).
Tras este fix: bundle principal 3.025 KB → 307 KB (gzip 1.755 KB → 92 KB). Habíamos quitado ~2,7 MB de datos de locale del camino crítico.
Fix 3: La fuga secundaria — PdfToolShell (ahorra ~600 KB)
Tras los dos primeros fixes, PageSpeed Insights aún marcaba "Reduce unused JavaScript" con culpables específicos:
pdf-*.js— 116 KB, 96% sin usarUPNG-*.js— 118 KB, 28% sin usar
¿Por qué se cargaban librerías PDF en la home?
Teníamos un componente compartido PdfToolShell que envuelve 30+ rutas de herramientas PDF (merge, split, rotate, etc.). No es una herramienta en sí, es una carcasa reutilizable. Estaba importada estáticamente en App.jsx:
import PdfToolShell from './components/PdfToolShell'
Y PdfToolShell hace esto al principio:
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import * as pdfjsLib from 'pdfjs-dist'
Así que toda la pila PDF se subía al bundle principal — aunque el usuario estuviera visitando /lorem-ipsum.
El fix fue el mismo tratamiento lazy():
const PdfToolShell = lazy(() => import('./components/PdfToolShell'))
Lección: lazy-loadear componentes de herramienta no basta si un componente "shell" compartido sigue importando estáticamente sus dependencias pesadas. Audita cada import estático por lo que arrastra transitivamente.
Fix 4: Logo (ahorra 640 KB)
Esto es vergonzoso. El logo de la web era un PNG de 1024×1024, 640 KB. Se mostraba a 32×32 píxeles en la nav. Nadie lo había cuestionado.
El arreglo tardó menos que leer este párrafo: soltamos logo.png en nuestro propio Compresor de Imágenes. El slider estaba ya al 80% (default). Le dimos a Download.
Resultado: 6,9 KB. Reducción del 99% sin diferencia visible a 32×32 — ni siquiera a 256×256, donde usamos el mismo logo para el Open Graph social.
Para el caso de uso de la tarjeta social (Twitter, Facebook, LinkedIn preview), queríamos que el logo se viera nítido escalado a ~256 px. Cogimos el original 1024×1024, lo soltamos en nuestra Redimensión de Imágenes, pusimos 256×256, lo pasamos por Compresión de Imagen al 90% de calidad y obtuvimos logo-256.png a 64 KB. La meta og:image ahora apunta ahí.
Dos herramientas, dos minutos, 633 KB de ahorro en cada carga de página.
Si estás leyendo esto en móvil y el logo de arriba se ve bien: esa es la imagen de 6,9 KB. La de 640 KB se veía idéntica.
Si tu propio sitio tiene un logo de más de 50 KB, el compresor de imágenes de ToolKoala está ahí. Suéltalo, ve el preview antes/después, baja el más pequeño. No se sube nada — tu logo no va a nuestro servidor, se procesa en tu navegador. Ese es el sentido del proyecto, incluyendo la parte en la que lo usamos sobre nosotros mismos.
Fix 5: Matar la dependencia de Google Fonts (ahorra ~1,2 s de retraso LCP)
Nuestro <head> se veía como el de cualquier web app:
<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">
PageSpeed reportó 1.258 ms de latencia de camino crítico por esto. El navegador tenía que:
- Resolver
fonts.googleapis.com(DNS, TLS) - Descargar el CSS (un lío de bloques
@font-facecon redirects) - Resolver
fonts.gstatic.com(DNS, TLS otra vez, otro origin!) - Descargar el archivo
.woff2real
Para una web de utilidades, Inter está bien pero no es necesaria. Ya teníamos un fallback CSS perfectamente válido:
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
En macOS resuelve a San Francisco (fuente de sistema de Apple). En Windows, Segoe UI. En Android, Roboto. Las tres son fuentes excelentes preinstaladas en el sistema. Usan cero ancho de banda y cero tiempo de carga.
Borramos el link de Google Fonts. La web se ve ~95% igual — distinta en Linux sin un buen sans-serif instalado, pero esa audiencia es pequeña y los fallbacks degradan con gracia.
La "latencia de camino crítico" de PageSpeed bajó 1,2 segundos.
Fix 6: Inlinear el CSS del bundle (ahorra 160 ms)
El recurso render-blocking restante era el bundle CSS que Vite emite:
<link rel="stylesheet" href="/assets/index-CcF97ram.css">
5,4 KB. Round-trip de unos 160 ms en una conexión típica — bloqueando el primer pintado.
Para un bundle CSS de este tamaño, inlinearlo en el HTML gana al cache. El coste de inlinear es que cada página HTML lleva ahora los 5,4 KB. El beneficio es una petición bloqueante menos en la primera carga.
Nuestro script de build (que post-procesa la salida de Vite para SSR) ahora lee el CSS y sustituye por un bloque <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: esto está bien para nuestros 5 KB de stylesheet. Si tienes 100 KB de CSS, inlinear rompe el cache entre páginas; déjalo externo (o extrae solo el critical CSS).
Lo que no arreglamos a propósito
Google AdSense y Google Analytics gtag. Ambos son scripts async que PageSpeed sigue señalando por forced reflow y tamaño de transferencia. Entre los dos suman 500+ ms de latencia de camino crítico.
Pero AdSense es cómo se financia la web. Quitarlo no es un trade-off de ingeniería; es uno de negocio. Lo mismo con Analytics — sin él volaríamos a ciegas sobre qué herramientas son populares y qué bugs no toca nadie.
En principio podríamos diferir AdSense para que cargue tras pintar el elemento LCP. Lo intentamos media hora; requería sobrescribir la lógica de inyección de AdSense y producía flicker visual al cargar los anuncios post-pintura. No vale la complejidad de ingeniería por ~200 ms de ahorro.
Si estás optimizando un sitio sin anuncios, tienes más opciones que nosotros. Tuvimos que aceptarlo.
Los resultados
El primer pintado con cache fría para https://www.toolkoala.com/ desde nuestro benchmark básico:
| Métrica | Antes | Después | Cambio |
|---|---|---|---|
| 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% |
| JS total en home | ~3 MB | 544 KB | -82% |
| CSS render-blocking | 160 ms | 0 ms (inlined) | -100% |
| Bloqueo 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) | — |
La web en sí no cambió — mismos componentes React, misma UI, mismo backend. Cada fix fue un cambio de configuración o de proceso de build.
Si estás distribuyendo una app React + Vite
Mira la salida del bundle en cada release. Son dos líneas en tu log de CI. Si algo supera los 500 KB, investiga. Vite te lo dice gratis.
React.lazy()conSuspensees la victoria de rendimiento más barata. Splitting a nivel de ruta tarda diez minutos en implementarse y suele ahorrar 50-90% del bundle principal.import.meta.globes el superpoder de Vite para lazy-loadear "muchos archivos del mismo tipo" — locales, iconos, posts de blog, lo que sea. Úsalo en vez de listas largas de imports estáticos.Audita imports transitivos. Un componente "shell" compartido que importa estáticamente librerías pesadas anulará todo tu lazy-loading de rutas. El bug de PdfToolShell nos costó un chunk de 600 KB en cada página hasta que lo notamos.
Tu logo es probablemente demasiado grande. Los logos en la mayoría de sitios se muestran a ≤64 px. Deberían pesar ≤10 KB. Usa la herramienta de resize de esta misma web si no tienes ImageMagick.
Cuestiona cada carga de fuente externa. Las fuentes de sistema en macOS, Windows y Android son todas excelentes. Si tu marca no depende de un tipo específico (y la mayoría no), borra el link de Google Fonts entero.
Inlinea CSS pequeño. Por debajo de ~10 KB, la latencia ahorrada al saltarte una petición supera el beneficio de cacheo de un archivo externo.
AdSense y Analytics están casi fuera de tu control. Acéptalo y optimiza lo que puedas.
Esto fueron unas seis horas de trabajo de ingeniería repartidas en dos días. Las mayores mejoras (route splitting + locale splitting) llevaron unas dos horas cada una. Las cuatro horas restantes fueron medición, testing de regresión, y escribir este post.
Si encontraste esto útil, las herramientas que salieron del build más limpio siguen aquí, siguen gratis, siguen corriendo enteramente en tu navegador:
- Compresión de imágenes (sí, incluyendo tu logo)
- Herramientas PDF (las que dispararon el insight de lazy-loading)
- Compresión de vídeo (FFmpeg.wasm, también lazy)
- OCR (Tesseract.js, también lazy)
- El blog completo con más notas de ingeniería
Todas ellas cargan en bien menos de un segundo en una conexión normal. Ahora.