StackPractices
advanced Por Mathias Paulenko

Optimización del Bundle JS: Guía Práctica Frontend

Guía práctica para medir y reducir el tamaño del bundle JavaScript con tree shaking, code splitting, reemplazo de dependencias, compresión y budgets en CI.

Introducción

flowchart diagram: Analizar bundle

Un bundle de JavaScript hinchado es una de las formas más rápidas de empeorar el Time to Interactive y el puntaje de Core Web Vitals. Vi equipos que shippingaban bundles de 2MB de JS a usuarios móviles y se preguntaban por qué su LCP era de 6 segundos. La buena noticia es que generalmente podés achicarlo mucho midiendo primero y luego combinando tree shaking, code splitting, reemplazo de dependencias, compresión y budgets en CI.

Esta guía es para desarrolladores frontend y tech leads que manejan budgets de performance. Deberías estar cómodo con npm, un bundler (webpack, Vite o Rollup) y CI básico. Si recién empezás con métricas de performance, leé nuestra guía de Core Web Vitals primero — esta guía asume que sabés qué miden LCP, CLS e INP.

Cubrimos webpack, Vite y Rollup. La mayoría de las técnicas aplican a los tres; donde difieren, muestro la config de cada uno. Para patrones de debounce y throttle que complementan la optimización del bundle, mirá nuestra recipe de debounce y throttle.

Cuándo Usarla

  • La carga inicial se siente lenta y Lighthouse marca payloads grandes de JavaScript.
  • Una auditoría de dependencias revela librerías sobredimensionadas o duplicadas.
  • Tenés rutas o componentes fuera del área visible que pueden cargar después.
  • Querés aplicar budgets de bundle en CI para que las regresiones fallen el build.

Cuándo NO Usarla

  • El bundle ya es pequeño y el cuello de botella real es la red o el tiempo de respuesta del servidor.
  • Estás optimizando antes de medir. Usá un profiler o analyzer primero.
  • Server-side rendering es el camino principal y el tamaño del HTML pesa más que el bundle JS.

Analizando el tamaño del bundle

Antes de cambiar nada, conseguí una imagen clara de qué hay dentro del bundle.

Webpack Bundle Analyzer

npm install -D webpack-bundle-analyzer
// webpack.config.js
const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer");

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

Vite Bundle Visualizer

npm install -D rollup-plugin-visualizer
// vite.config.js
import { visualizer } from "rollup-plugin-visualizer";

export default {
  plugins: [
    visualizer({
      filename: "bundle-stats.html",
      gzipSize: true,
      brotliSize: true,
    }),
  ],
};

Source Map Explorer

npm install -D source-map-explorer
npx source-map-explorer dist/*.js

Recurro a source-map-explorer cuando bundle-analyzer muestra un chunk grande pero no puedo distinguir qué archivos fuente contribuyeron a él. Lee el source map y muestra un árbol de qué terminó dónde — útil para rastrear dependencias transitivas que se colaron por un sub-import.

Tree Shaking

El tree shaking elimina exportaciones no usadas, pero solo funciona con ES modules y builds de producción. Si importás con CommonJS (require()), el bundler no puede analizar estáticamente qué usás, así que incluye todo. Por eso lodash (CommonJS) pesa 70KB aunque solo uses chunk, mientras que lodash-es (ESM) pesa 1KB para la misma función. ¿Todavía envolvés código en IIFEs? El Patrón Module explica la migración a ES modules — el paso que realmente vuelve tu propio código tree-shakeable.

// Mal: importa todo lodash
import _ from "lodash";
const result = _.chunk([1, 2, 3, 4], 2);

// Bien: importa solo la función
import { chunk } from "lodash-es";
const result = chunk([1, 2, 3, 4], 2);

Webpack

// webpack.config.js
module.exports = {
  mode: "production",
  optimization: {
    usedExports: true,
    sideEffects: false,
  },
};
// package.json
{
  "sideEffects": ["*.css", "./src/polyfills.js"]
}

Vite

// vite.config.js
export default {
  build: {
    rollupOptions: {
      treeshake: true,
    },
  },
};

Code Splitting

El code splitting divide tu bundle en chunks más chicos que se cargan bajo demanda. El objetivo es shippingar menos JavaScript en la carga inicial y fetchear el resto cuando el usuario lo necesita.

Splitting por ruta en React

import { lazy, Suspense } from "react";

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

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  );
}

Splitting por componente

No todo se divide por ruta. Componentes pesados — charts, editores, mapas — se dividen mejor individualmente así solo cargan cuando el usuario interactúa con ellos.

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

const Chart = lazy(() => import("./components/Chart"));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>Show chart</button>
      {showChart && (
        <Suspense fallback={<div className="chart-skeleton" />}>
          <Chart data={chartData} />
        </Suspense>
      )}
    </div>
  );
}

Webpack splitChunks

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: "all",
      minSize: 20000,
      maxSize: 244000,
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: "vendors",
          chunks: "all",
          priority: 10,
        },
      },
    },
  },
};

Dynamic Imports

// Cargar módulo bajo demanda
const module = await import("./heavy-module.js");
module.doSomething();

// Prefetch en hover
button.addEventListener("mouseenter", () => {
  import(/* webpackPrefetch: true */ "./Chart");
}, { once: true });

// Preload chunk crítico en paralelo
import(/* webpackPreload: true */ "./CriticalChart");

Uso prefetch con moderación — es fácil pasarse y terminar cargando todo. Una buena regla: hacé prefetch solo de la próxima ruta probable (ej: el dashboard cuando el usuario está en el login) y preload solo de assets que la página actual definitivamente necesita. Si hacés prefetch de cinco rutas, volvés a shippingar todo el bundle, solo distribuido en tiempo ocioso.

Reemplazo de Dependencias

Reemplazos comunes que reducen el tamaño del bundle:

DeAAhorro
moment.js (293KB)date-fns (13KB) o dayjs (2KB)grande
lodashlodash-es o métodos nativosgrande
axiosfetch nativo13KB
uuidcrypto.randomUUID()7KB
// Reemplazar lodash por métodos nativos de array
const result = array
  .map((x) => x * 2)
  .filter((x) => x > 10)
  .reduce((sum, x) => sum + x, 0);

// Reemplazar uuid por crypto nativo
const id = crypto.randomUUID();

// Reemplazar axios por fetch
const res = await fetch("/api/users");
const data = await res.json();

Consultá los tamaños en bundlephobia.com antes de instalar un nuevo paquete. Hago de esto una regla en cada revisión de PR — sin checkeo de tamaño no hay dependencia nueva. Me salvó a mi equipo de meter librerías de 500KB más de una vez.

Compresión

La compresión es la ganancia más barata en optimización de bundle. Brotli y gzip reducen el tamaño transferido entre 60-80% sin cambios de código. La única pregunta es si comprimir en build o en el CDN — prefiero build porque ahorra CPU en el edge y te da control explícito.

Compresión en build

// Vite con vite-plugin-compression2
import { compression } from "vite-plugin-compression2";

export default {
  plugins: [
    compression({ algorithm: "gzip", threshold: 10240 }),
    compression({ algorithm: "brotliCompress", threshold: 10240 }),
  ],
};

Archivos precomprimidos en Nginx

server {
  gzip_static on;
  brotli_static on;

  location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

Serví brotli primero; gzip es el fallback para navegadores antiguos.

Compresión a nivel CDN

Si estás en Cloudflare, Fastly o Vercel, el CDN puede auto-comprimir las respuestas. Pero pre-comprimir en build es mejor — ahorra CPU en el edge y te deja controlar la calidad explícitamente.

# vercel.json
{
  "functions": {},
  "routes": [
    {
      "src": "/assets/(.*).js",
      "headers": {
        "Content-Encoding": "br",
        "Cache-Control": "public, max-age=31536000, immutable"
      }
    }
  ]
}

Seteá Cache-Control: immutable para assets con fingerprint (archivos con hash en el nombre). El navegador nunca necesita revalidarlos porque el hash cambia cuando cambia el contenido. Vi equipos saltear esto y pagarlo con requests 304 repetidos en cada navegación.

Manejo de Polyfills

Los polyfills son un asesino silencioso del bundle. Si importás core-js/stable globalmente, shippingás todos los polyfills a todos los navegadores — incluyendo navegadores modernos que no necesitan el 90% de ellos. Vi esto sumar 80KB a un bundle que debería pesar 40KB.

// Mal: importar todos los polyfills
import "core-js/stable";

// Bien: importar solo lo que necesitás
import "core-js/stable/promise";
import "core-js/stable/array/flat";

// Mejor: dejar que Babel inyecte polyfills según uso
// babel.config.js
module.exports = {
  presets: [
    ["@babel/preset-env", { useBuiltIns: "usage", corejs: 3 }],
  ],
};

El setting usage escanea tu código e inyecta solo los polyfills para las APIs que usás, apuntando a los navegadores de tu .browserslistrc. Seteá tus targets de navegador con cuidado — si soportás IE11, vas a traer muchos más polyfills que si apuntás a las últimas 2 versiones de navegadores modernos. Para la mayoría de los proyectos en 2026, droppear IE11 ahorra 40-60KB de polyfills solos.

Module Federation

Usá module federation para compartir dependencias entre micro-frontends en runtime. Esto es poderoso para equipos grandes con ciclos de deploy independientes — cada equipo shippinga su propia entrada remota, y dependencias compartidas como React se cargan una sola vez. Pero agrega complejidad de runtime: negociación de versiones, cacheo de entradas remotas y manejo de fallbacks. Solo lo usaría cuando tenés 3+ equipos deployando independientemente y el costo de coordinación de un monorepo es muy alto.

// webpack.config.js: host
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "host",
      remotes: {
        remoteApp: "remoteApp@https://cdn.example.com/remoteEntry.js",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^19.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^19.0.0" },
      },
    }),
  ],
};
// Host app
import { lazy, Suspense } from "react";

const RemoteWidget = lazy(() => import("remoteApp/Widget"));

function App() {
  return (
    <Suspense fallback={<div>Loading widget...</div>}>
      <RemoteWidget />
    </Suspense>
  );
}

Monitoreo del Bundle

Una vez que optimizaste el bundle, tenés que mantenerlo chico. Los budgets de bundle en CI atrapan regresiones antes de que lleguen a producción. Seteo budgets en cada proyecto — sin ellos, las dependencias se cuelan y el bundle crece 10-20% por trimestre.

Budgets de tamaño en webpack

// webpack.config.js
module.exports = {
  performance: {
    hints: "warning",
    maxAssetSize: 244000,
    maxEntrypointSize: 244000,
    assetFilter: (filename) => !filename.endsWith(".map"),
  },
};

Chequeo de tamaño en CI con bundlesize

// package.json
{
  "scripts": {
    "size-check": "bundlesize"
  },
  "bundlesize": [
    { "path": "dist/assets/*.js", "maxSize": "100KB" },
    { "path": "dist/assets/*.css", "maxSize": "20KB" }
  ]
}

Optimización de Imágenes

Las imágenes suelen pesar más que el JavaScript en total. Vi landing pages con 4MB de PNGs sin optimizar al lado de un bundle de 200KB de JS — el equipo estaba optimizando lo que no era.

Formatos modernos

<picture>
  <source srcset="/img/hero.avif" type="image/avif" />
  <source srcset="/img/hero.webp" type="image/webp" />
  <img src="/img/hero.jpg" alt="Vista del dashboard" width="1200" height="630" loading="lazy" />
</picture>

AVIF comprime 50% mejor que JPEG. WebP es el fallback con 25-35% de ahorro. Serví ambos con <picture> y un fallback JPEG para navegadores antiguos.

Imágenes responsive

<img
  srcset="/img/hero-480.webp 480w, /img/hero-800.webp 800w, /img/hero-1200.webp 1200w"
  sizes="(max-width: 600px) 480px, (max-width: 900px) 800px, 1200px"
  src="/img/hero-800.webp"
  alt="Dashboard"
  loading="lazy"
  decoding="async"
/>

No shippingás una imagen de 1200px a un teléfono de 375px. srcset deja que el navegador elija el tamaño correcto. Aprendí esto por las malas cuando Lighthouse marcó una imagen hero de 1.2MB en móvil que solo se mostraba a 375px de ancho.

Optimización de imágenes en build

npm install -D vite-imagetools
// vite.config.js
import { imagetools } from "vite-imagetools";

export default {
  plugins: [
    imagetools({
      defaultDirectives: (url) => ({
        format: "webp",
        quality: 80,
      }),
    }),
  ],
};

Para webpack, usá image-webpack-loader para auto-optimizar imágenes en build. Seteá quality en 80 para fotos y 90 para screenshots con texto.

Carga de Fuentes

Las fuentes bloquean el renderizado de texto si no se manejan con cuidado. Una fuente custom que tarda 2 segundos en cargar causa un flash de texto invisible (FOIT) o un flash de texto sin estilo (FOUT).

font-display swap

@font-face {
  font-family: "Inter";
  src: url("/fonts/inter-var.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}

font-display: swap muestra texto fallback inmediatamente y lo swapea cuando la fuente carga. Usá optional para fuentes no críticas — el navegador puede saltear la carga en conexiones lentas.

Preload de fuentes críticas

<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin />

Hacé preload del único archivo de fuente que importa para el texto above-the-fold. No hagas preload de todos los pesos — una vez vi un sitio con preload de 12 archivos de fuente, sumando 1.8MB al critical path.

Subset de fuentes

npx fonttools subsetting --input inter.ttf --output inter-subset.woff2 --unicodes "U+0020-007F"

Hacé subset a los caracteres que usás. Una Inter variable completa pesa 800KB. Con subset a Latin baja a 60KB. Para un sitio en español, incluí Latin Extended (U+00C0-00FF) para los caracteres acentuados. Usá woff2 — es 30% más chico que woff.

Optimización de CSS

El CSS es más chico que el JS, pero el CSS sin usar bloquea el renderizado igual. Un archivo de 200KB de CSS con 80% de selectores sin usar ralentiza cada carga de página.

Purge de CSS sin usar

// vite.config.js con Tailwind v4
import { tailwindcss } from "@tailwindcss/vite";

export default {
  plugins: [tailwindcss()],
};

Tailwind v4 purga automáticamente basado en tus archivos de contenido. Para proyectos sin Tailwind, usá PurgeCSS:

// postcss.config.js
const purgecss = require("@fullhuman/postcss-purgecss");

module.exports = {
  plugins: [
    purgecss({
      content: ["./src/**/*.html", "./src/**/*.tsx"],
      defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
    }),
  ],
};

Vi bundles de CSS bajar de 280KB a 12KB después de purgar. El catch: las clases dinámicas construidas en runtime se purgan. Safelistalas o usá nombres completos de clase en tus templates.

CSS crítico

Extraé el CSS above-the-fold e inlinéalo en <head> para eliminar el render-blocking:

<style>
  /* CSS crítico: hero, nav, tipografía */
  body { margin: 0; font-family: system-ui, sans-serif; }
  .hero { min-height: 60vh; }
</style>
<link rel="stylesheet" href="/css/full.css" media="print" onload="this.media='all'"" />

Usá critters para Astro/Next.js o critical para HTML estático. El truco de CSS async (media="print" + onload) difiere el stylesheet completo sin bloquear el first paint.

Service Worker Caching

Un service worker puede cachear assets localmente, eliminando downloads repetidos. Esto no reduce el tamaño inicial del bundle, pero mejora dramáticamente el rendimiento de visitas repetidas.

// sw.js
const CACHE = "app-v1";
const ASSETS = ["/", "/css/main.css", "/js/app.js"];

self.addEventListener("install", (event) => {
  event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(ASSETS)));
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => cached || fetch(event.request)),
  );
});

Usá Workbox para estrategias de cacheo de nivel producción:

// sw.js con Workbox
import { registerRoute } from "workbox-routing";
import { CacheFirst, StaleWhileRevalidate } from "workbox-strategies";

registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({ cacheName: "images" }),
);

registerRoute(
  ({ request }) => request.destination === "script",
  new StaleWhileRevalidate({ cacheName: "scripts" }),
);

Cacheá imágenes con CacheFirst (rara vez cambian) y scripts con StaleWhileRevalidate (serví el cacheado, fetcheá el actualizado en background). Para más sobre el comportamiento del event loop que afecta cuándo corren los service workers, mirá nuestra recipe del event loop de JavaScript.

Caso Real: 2.4MB a 380KB

Trabajé en un dashboard React que shippingaba un bundle de 2.4MB de JS. Acá está lo que hicimos y el impacto de cada paso:

PasoTécnicaTamaño bundleAhorro
0Baseline2,400KB—
1Reemplazar moment.js por dayjs2,120KB-280KB
2Reemplazar lodash por lodash-es + tree shaking1,780KB-340KB
3Code splitting por ruta (5 rutas)1,100KB inicial-680KB inicial
4Component splitting para charts (Recharts)680KB inicial-420KB inicial
5Remove dependencias sin usar (npm prune)620KB-60KB
6Compresión brotli380KB transferido-240KB transferido

El insight clave: los pasos 1-2 fueron quick wins que tomaron una tarde. El paso 3 fue el mayor win individual — la mayoría de los usuarios solo visitaba una ruta, así que cargar las cinco upfront era desperdicio puro. El paso 4 sacó la librería de charts del bundle inicial entero. Los charts solo cargaban cuando el usuario clickeaba la tab “Analytics”.

Después de estos cambios, el LCP bajó de 4.2s a 1.1s en un Android de gama media con 3G. ¿Esfuerzo total? 2 días. La lección: medí primero, apuntá a los items más grandes, y no sobre-ingenieres — nunca necesitamos module federation.

Buenas Prácticas

  • Medí primero con un bundle analyzer. Perdí un día swapeando dependencias antes de darme cuenta de que el 60% del bundle era una sola librería de charts que no necesitaba cargar upfront.
  • Preferí APIs nativas sobre librerías cuando el navegador ya soporta lo que necesitás.
  • Dividí por ruta primero, luego por componentes pesados fuera del área visible.
  • Revisá los diffs de dependencias antes de actualizar un paquete — un update minor puede duplicar el tamaño de un paquete.
  • Definí budgets de bundle en CI y fallá builds que los superen. Sin enforcement, los budgets son solo sugerencias.
  • Comprimí assets con gzip y brotli, y serví .br cuando sea posible.

Errores Comunes

  • Adivinar el cuello de botella: me pasó — gasté horas en tree shaking cuando el problema real era una imagen de 3MB. Analizá primero.
  • Importar librerías completas: import _ from "lodash" trae todo el paquete. Usá lodash-es con named imports.
  • Dividir en demasiados chunks: cientos de chunks chicos perjudican el cache y agregan overhead HTTP. Apunto a 5-15 chunks a nivel ruta, no 200 chunks de componente.
  • Ignorar la compresión: servir JS sin comprimir desperdicia ancho de banda. Brotli es ganancia gratis.
  • Olvidar el alcance de los polyfills: polyfills globales inflan navegadores modernos. Usá inyección basada en uso.
  • Sobre-ingeniería con module federation: agrega complejidad que equipos pequeños rara vez necesitan. Vi equipos adoptarlo para un proyecto de 2 personas — era overkill.

See Also

Preguntas frecuentes

¿Qué es tree shaking y cómo funciona?

El tree shaking elimina exportaciones no usadas de los ES modules. Necesita sintaxis import/export, un build de producción y paquetes marcados como libres de side effects. Webpack requiere mode: "production"; Vite y Rollup lo hacen por defecto.

¿En qué se diferencia code splitting de tree shaking?

El tree shaking remueve código muerto. El code splitting divide el bundle en chunks más chicos que se cargan bajo demanda. Usá ambos: tree shake primero, luego dividí por ruta o componente pesado.

¿Cuál es la diferencia entre prefetch y preload?

Prefetch carga un recurso durante el tiempo ocioso para uso futuro probable. Preload lo carga inmediatamente en paralelo con la página actual. Usá webpackPrefetch para las siguientes rutas y webpackPreload para assets críticos de la página actual.

¿Qué herramientas identifican qué dependencias inflan mi bundle?

Usá webpack-bundle-analyzer, rollup-plugin-visualizer o source-map-explorer. Consultá tamaños en bundlephobia.com antes de instalar. Ejecutá npm ls para detectar dependencias duplicadas.

¿Debería usar gzip o brotli?

Usá ambos. Brotli comprime texto un 15-25% mejor. Serví .br primero y .gz como fallback. La mayoría de CDNs y hosts estáticos soportan brotli.

¿Cuándo debería considerar module federation para el tamaño del bundle?

Module federation permite que varias apps compartan dependencias en runtime, reduciendo código duplicado. Agrega overhead de runtime para cargar entradas remotas. Usalo para equipos grandes con despliegues independientes, no para apps pequeñas.