wandres.dev
JAVASCRIPT II · Presupuesto y análisis del bundle

Encontrar la dependencia gorda y decidir qué hacer con ella

El procedimiento para localizar qué import concreto arrastra los kilobytes, los cuatro patrones que hacen gorda a una dependencia, y el árbol de decisión entre sustituir, recortar, diferir o quedarse.

⏱ 19 min

En casi todos los bundles hay una o dos dependencias que se llevan una porción desproporcionada del peso, y en la mayoría de los casos no es porque la biblioteca sea mala sino porque se está usando de una forma que impide recortarla. Encontrarla es fácil; lo interesante es el paso siguiente, que es averiguar qué línea de tu código la está arrastrando y decidir entre cuatro salidas que tienen costes muy distintos.

🎯 Al terminar esta lección sabrás
  • Rastrear una dependencia desde el bundle hasta el import concreto que la incluye.
  • Reconocer los cuatro patrones que convierten una biblioteca razonable en un problema.
  • Estimar el coste de sustituir frente al de recortar frente al de diferir.
  • Comprobar el peso real de un paquete antes de instalarlo.

Del bundle al import: la cadena de razones

Saber que xyz ocupa 90 KB no sirve para nada. Lo que sirve es saber por qué está ahí, y para eso los empaquetadores tienen una función poco usada: explicar la cadena de importaciones que lleva a un módulo.

Con webpack, el propio informe de estadísticas trae las razones de cada módulo. Con Rollup y Vite, el gráfico de dependencias del informe muestra los padres. Y con esbuild, el metafichero lo trae todo en JSON y se puede consultar directamente:

// razones.mjs — quien importa a quien, sobre el metafichero de esbuild
import { readFileSync } from 'node:fs';
const meta = JSON.parse(readFileSync('meta.json', 'utf8'));
const objetivo = process.argv[2];   // p.ej. 'node_modules/moment/moment.js'

for (const [salida, info] of Object.entries(meta.outputs)) {
  for (const [entrada, detalle] of Object.entries(info.inputs ?? {})) {
    if (!entrada.includes(objetivo)) continue;
    console.log(`${salida}  <-  ${entrada}  (${detalle.bytesInOutput} bytes)`);
  }
}

// Y quien la importa, desde el grafo de entradas
for (const [entrada, info] of Object.entries(meta.inputs)) {
  for (const imp of info.imports ?? []) {
    if (imp.path.includes(objetivo)) console.log(`importado por: ${entrada}`);
  }
}

El resultado típico de este ejercicio es descubrir que la biblioteca de 90 KB entra por un único fichero de tu código que usa una única función, muchas veces en un módulo de utilidades compartido que se importa desde todas partes. Ese es el mejor caso posible y el más frecuente.

Los cuatro patrones

Patrón uno: la importación total de una biblioteca modular. La biblioteca está bien construida y se puede recortar, pero tu código la importa entera:

import _ from 'lodash';                 // arrastra todo
const nombres = _.uniqBy(usuarios, 'id');
import uniqBy from 'lodash-es/uniqBy';  // arrastra solo lo necesario
const nombres = uniqBy(usuarios, 'id');

La diferencia entre esas dos líneas está en el orden de los 70 KB. El paquete con módulos ES es importante: la versión clásica en formato CommonJS no se puede recortar, por las razones que verás en tree shaking.

Patrón dos: la biblioteca con datos incorporados. Bibliotecas de fechas con todas las localizaciones, bibliotecas de zonas horarias con la base de datos completa, validadores con listas de dominios, bibliotecas de números de teléfono con los metadatos de todos los países. Los datos son el 80 o el 90 por ciento del peso y casi siempre necesitas una fracción.

// Antes: 180 KB, todas las localizaciones
import { format } from 'date-fns';
import { es, en, fr, de, it, pt, nl } from 'date-fns/locale';

// Despues: la localizacion se carga a demanda, ~4 KB cada una
const locales = {
  es: () => import('date-fns/locale/es'),
  en: () => import('date-fns/locale/en-US'),
};
const { default: locale } = await locales[idioma]();

Patrón tres: la dependencia transitiva. No la has instalado tú; la arrastra otra. Un componente de interfaz que depende de una biblioteca de animación, un cliente de API que depende de un polyfill de peticiones, un validador que depende de una biblioteca de internacionalización. Es la más difícil de ver y la que más frustra, porque no puedes cambiarla sin cambiar el padre.

Patrón cuatro: el duplicado por versión. Dos dependencias tuyas usan versiones incompatibles de la misma biblioteca y las dos acaban en el bundle. Se detecta con el árbol de dependencias y se resuelve, cuando se puede, forzando una única versión:

{
  "overrides": {
    "semver": "^7.6.0"
  }
}

Forzar versiones es una herramienta afilada: si las dos versiones eran incompatibles por una razón real, la resolución rompe algo. Hay que ejecutar las pruebas después, no antes.

El árbol de decisión

Encontrada la dependencia, hay cuatro salidas. El criterio para elegir es el coste de la salida frente a los kilobytes que ahorra.

Recortar la importación. Coste: minutos. Ahorro: puede ser el 90 por ciento del peso de la dependencia. Siempre es lo primero que hay que intentar, y muchas veces resuelve el problema entero. Requiere que la biblioteca esté publicada en módulos ES y que no tenga efectos secundarios en el nivel superior.

Diferir la carga. Coste: horas, y algún cambio en la interfaz porque hay un estado de carga que gestionar. Ahorro: el 100 por ciento del peso, en el arranque. Es la salida correcta para todo lo que solo se usa tras una interacción: editores, mapas, gráficas, selectores complejos, reproductores.

Sustituir por una alternativa ligera. Coste: días, incluida la migración y las pruebas, y un riesgo real de que la alternativa no cubra un caso límite del que dependías sin saberlo. Ahorro: la diferencia entre las dos. Solo merece la pena si la diferencia es grande y el uso es superficial.

Escribirlo tú. Coste: variable, y una deuda de mantenimiento permanente. Es la salida correcta solo cuando usas una fracción muy pequeña de la biblioteca y esa fracción es simple. Un debounce de ocho líneas, un formateador de moneda que ya te da la API de internacionalización del navegador, una comparación profunda para objetos que sabes que son planos. Es la salida equivocada para criptografía, para parseo de fechas con zonas horarias y para cualquier cosa con casos límite que no conoces.

Una tabla de sustituciones que aparece constantemente, con las cifras de referencia:

Dependencia Peso aprox. comprimido Alternativa Peso
Biblioteca de fechas clásica y monolítica 70 KB API nativa de fechas y formato del navegador 0 KB
Cliente HTTP con envoltorio 13 KB fetch nativo con un envoltorio propio ~0,5 KB
Biblioteca de utilidades importada entera 25 KB Importaciones puntuales o equivalentes nativos 1-3 KB
Biblioteca de identificadores únicos 4 KB crypto.randomUUID() 0 KB
Biblioteca de animación completa 40 KB Animaciones CSS o la API de animaciones web 0 KB

Las dos primeras filas merecen un matiz honesto: la API nativa de formato de fechas del navegador cubre formateo e internacionalización magníficamente, y no cubre aritmética de fechas ni parseo de cadenas arbitrarias. Si tu código suma meses, calcula diferencias en días laborables o parsea formatos raros, la biblioteca sigue siendo la respuesta correcta y lo que toca es importarla bien.

Mide el paquete antes de instalarlo, y desconfía del tamaño que anuncia su documentación

El momento más barato para resolver un problema de peso es cinco minutos antes de instalarlo, y hay dos comprobaciones que caben en esos cinco minutos.

La primera: el tamaño que anuncia la documentación de un paquete casi nunca es el que vas a pagar. «Solo 1,2 KB» suele referirse al núcleo mínimo, sin las dependencias, sin el punto de entrada que de verdad vas a importar y a veces midiendo la versión mínima con la mitad de las funciones desactivadas. La cifra que importa es el coste de instalación real en tu bundle, y se mide instalando y comparando el antes y el después:

npm run build && node scripts/tamanos.mjs > /tmp/antes.txt
npm i la-biblioteca
# usala de verdad en el sitio donde iria
npm run build && node scripts/tamanos.mjs > /tmp/despues.txt
diff /tmp/antes.txt /tmp/despues.txt

Cinco minutos, y el número resultante es incontestable porque es tu bundle con tu configuración y tu uso.

La segunda comprobación, y esta es la que separa: mira si el paquete publica módulos ES y si declara sideEffects. Es lo que determina si vas a poder recortarlo o no, y se lee del package.json del propio paquete sin instalarlo:

npm view la-biblioteca dist-tags versions
npm view la-biblioteca --json | grep -E '"module"|"exports"|"sideEffects"|"dependencies"'

Un paquete sin campo module ni exports con condición de importación, publicado solo en CommonJS, no se puede recortar: te llevas todo lo que contenga, siempre. Un paquete sin "sideEffects": false obliga al empaquetador a asumir lo peor y conservar módulos que quizá no usas. Y la lista de dependencias te dice qué vas a arrastrar sin haberlo pedido.

Un tercer indicador que vale más que cualquier cifra: cuántas dependencias transitivas trae. Un paquete con cero dependencias es un paquete cuyo peso conoces. Uno con treinta es una apuesta, porque cada una de esas treinta puede traer las suyas y ninguna está bajo tu control. La instalación en un directorio temporal te lo dice antes de tocar tu proyecto:

mkdir /tmp/probar && cd /tmp/probar && npm init -y >/dev/null
npm i la-biblioteca --omit=dev
du -sh node_modules
npm ls --all --depth=99 2>/dev/null | wc -l

El tamaño en disco no es el tamaño en el bundle —incluye documentación, tipos y ficheros de otras plataformas— pero un node_modules de 40 MB para una utilidad es una señal que merece dos minutos más de investigación antes de comprometerse.

Cuando no hay salida y hay que quedarse

A veces la dependencia gorda es imprescindible, no se puede recortar, no hay alternativa y se necesita en el arranque. Ocurre. En ese caso quedan tres mitigaciones que no eliminan el peso pero reducen su impacto.

Sepárala en su propio chunk. Una dependencia grande y estable en un fichero aparte con hash propio se cachea durante meses y no se invalida cada vez que despliegas tu código. El usuario recurrente deja de pagarla.

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules/la-biblioteca-gorda')) return 'vendor-gorda';
        },
      },
    },
  },
};

Cárgala con prioridad baja si no bloquea el primer pintado. Si el módulo se necesita pero no de inmediato, un <link rel="prefetch"> o un import() disparado tras el pintado la sacan de la ruta crítica sin quitarle nada al usuario.

Documenta la decisión. Una nota en el registro de excepciones del presupuesto, con la fecha y el motivo, para que dentro de un año alguien pueda reevaluarla en lugar de asumir que es intocable. Las dependencias imprescindibles dejan de serlo con el tiempo, y sin la nota nadie vuelve a mirar.

⚔️ Reto práctico

Localiza la dependencia de terceros más pesada de tu bundle y rastrea la cadena hasta el import concreto. Anota cuántas funciones de esa biblioteca usa tu código de verdad, buscándolas en el repositorio. Después recorre el árbol de decisión y elige una salida, con el coste estimado en horas y el ahorro estimado en kilobytes comprimidos. Si el cociente entre kilobytes ahorrados y horas de trabajo es peor que en otra dependencia de la lista, empieza por la otra.