wandres.dev
CODE SPLITTING · dividir el bundle

El coste del splitting: encontrar el equilibrio

Dividir no es gratis: cada chunk es una petición, y los cortes encadenados producen cascadas donde el navegador descubre un chunk solo tras cargar el anterior. El over-splitting cambia el coste de transferir bytes por el coste de coordinar peticiones. Encontrar el equilibrio es la verdadera destreza del code splitting.

⏱ 17 min

Después de aprender a dividir, hay que aprender a no dividir de más. Cada frontera que introduces convierte un tramo de bytes en una petición de red separada, con su latencia y su descubrimiento tardío. Llevado al extremo, un grafo troceado en decenas de chunks minúsculos sustituye el problema que resolvías —enviar demasiado de golpe— por otro igual de dañino: una cascada de peticiones que serializa el arranque. El code splitting maduro no es dividir todo lo posible, es encontrar el punto donde el coste de partir iguala al beneficio de diferir.

🎯 Al terminar esta lección sabrás
  • Entender la cascada de peticiones que producen los cortes encadenados.
  • Reconocer el over-splitting y su coste fijo por chunk bajo HTTP/2 y HTTP/3.
  • Usar la precarga para aplanar cascadas sin renunciar a la división.
  • Formular la heurística de equilibrio entre número de chunks y tamaño.

La cascada de peticiones

El peor patrón del splitting es el corte encadenado: el chunk de la entrada importa dinámicamente el chunk A, que al ejecutarse importa el chunk B, que a su vez importa el C. El navegador no puede pedir B hasta haber descargado y ejecutado A, porque hasta entonces no sabe que B existe. El resultado es una cascada: peticiones en serie, cada una esperando a que la anterior revele la siguiente.

sequenceDiagram
participant N as Navegador
participant S as Servidor
N->>S: pide entry
S-->>N: entry revela chunk A
N->>S: pide chunk A
S-->>N: A revela chunk B
N->>S: pide chunk B
S-->>N: B revela chunk C
N->>S: pide chunk C
S-->>N: al fin interactivo

Cada eslabón de la cadena suma una vuelta completa de red. Con latencias reales, tres o cuatro saltos encadenados bastan para que el arranque diferido tarde más que el monolito que intentabas evitar. La cascada es traicionera porque no se ve en el tamaño total —los bytes son los mismos—, se ve solo en el tiempo, y solo bajo latencia real.

⚠️
La cascada es un coste de latencia, no de tamaño

Puedes tener un splitting con menos bytes totales que rinda peor que un bundle mayor, porque el cuello de botella dejó de ser el ancho de banda y pasó a ser la latencia de descubrimiento. Medir solo kilobytes te oculta este problema por completo. Hay que medir el tiempo hasta interactivo bajo throttling de red, que es donde la cascada aflora.

La cascada se lee a simple vista en la pestaña de red del navegador: las barras de las peticiones aparecen escalonadas en diagonal, cada una empezando donde termina la anterior, en lugar de alinearse en una columna paralela. Esa escalera es la firma visual del descubrimiento encadenado, y es lo primero que hay que buscar cuando un arranque dividido rinde peor de lo esperado. Un splitting sano produce barras que arrancan casi juntas; uno enfermo, una escalera que desciende hacia la derecha.

El remedio conceptual es descubrir pronto lo que se necesitará, en vez de encadenar el descubrimiento. Comparadas lado a lado, las dos formas dejan claro el problema:

// Anti-patron: cascada. B no se descubre hasta ejecutar A.
const a = await import("./a.ts");
const b = await a.cargarB();

// Mejor: pedir en paralelo lo que ya se sabe necesario
const [a2, b2] = await Promise.all([
  import("./a.ts"),
  import("./b.ts"),
]);

La segunda forma colapsa dos vueltas de red en una sola tanda. Cuando no puedes reestructurar el código así, la precarga logra el mismo efecto desde el HTML sin tocar la lógica, y a ella llegamos enseguida.

Over-splitting: el coste fijo por chunk

Cada chunk tiene un coste que no depende de su tamaño. Una petición HTTP arrastra cabeceras, una entrada en el grafo de módulos, una resolución de URL y —crucial— un mínimo por debajo del cual la compresión deja de rendir: comprimir un archivo de 200 bytes apenas ahorra nada, y el diccionario de brotli o gzip pesa casi tanto como el contenido. Cientos de chunks minúsculos desperdician compresión y saturan el navegador de coordinación.

ℹ️
HTTP/2 y HTTP/3 mitigan, no anulan

Se suele decir que con HTTP/2 el multiplexado hace gratis tener muchas peticiones. Es a medias cierto: HTTP/2 y HTTP/3 eliminan el límite de conexiones y el head-of-line blocking a nivel de conexión, así que muchos chunks paralelos ya no bloquean como en HTTP/1.1. Pero no eliminan la latencia del descubrimiento encadenado —una cascada sigue siendo secuencial por dependencia, no por protocolo— ni el coste de CPU de parsear cientos de módulos, ni la pérdida de compresión. El protocolo abarata el paralelo, no la serie.

El over-splitting es, entonces, cambiar un coste por otro: reduces los bytes del arranque inicial pero inflas el número de peticiones, la coordinación y la pérdida de compresión. Existe un punto —distinto para cada app— donde partir un chunk más ya no ahorra tiempo percibido y solo añade overhead. Cruzar ese punto es optimizar la métrica equivocada.

El over-splitting suele ser autoinfligido. Nace de reglas manuales bienintencionadas —un chunk por componente, un chunk por dependencia— que multiplican los archivos creyendo que más granularidad es siempre mejor. Es la degeneración de la apuesta del nivel anterior: cada chunk minúsculo es una deduplicación que no compensa su coste fijo. Por eso el consejo recurrente es dejar que el automático agrupe y reservar la división manual para las piezas grandes y verdaderamente independientes, no para trocear por trocear.

Hay además un efecto de compresión que agrava el over-splitting. Los algoritmos como brotli comprimen mejor cuanto más contexto tienen: un archivo grande deja al compresor encontrar más repeticiones que diez archivos pequeños con el mismo contenido total. Partir en exceso no solo multiplica las peticiones, también empeora la ratio de compresión de cada trozo, de modo que la suma de los chunks comprimidos pesa más que el bundle comprimido del que salieron. Es el caso extremo en que dividir aumenta los bytes en lugar de reducirlos.

📝
El umbral práctico ronda las decenas de kilobytes

No hay un número universal, pero la experiencia de 2026 sitúa el tamaño mínimo útil de un chunk en el orden de las decenas de kilobytes antes de comprimir: por debajo, el coste fijo de la petición y la compresión perdida suelen comerse el ahorro. Los sistemas de chunking modernos exponen ese umbral como parámetro —un minSize— precisamente para que puedas fundir automáticamente lo que caiga por debajo. Fijar ese umbral es más productivo que decidir chunk a chunk.

Hay un coste que sobrevive incluso al mejor protocolo: la CPU. Cada chunk es un módulo que el motor registra, resuelve y evalúa por separado, con su propio marco de ejecución. Cientos de módulos diminutos reparten el mismo trabajo en más unidades de las necesarias, y esa sobrecarga de coordinación en el hilo principal no la arregla HTTP/3 ni la caché: solo se evita no fragmentando de más.

Aplanar la cascada con precarga

La cascada nace del descubrimiento tardío: el navegador ignora que necesitará B hasta ejecutar A. La cura es adelantar ese descubrimiento con modulepreload. Si al servir la entrada le dices al navegador que también va a necesitar A, B y C, puede pedirlos en paralelo desde el primer instante, colapsando la cascada en una sola tanda.

<!-- Declarar los chunks del arranque para pedirlos en paralelo -->
<link rel="modulepreload" href="/assets/ruta-editor-a1b2.js" />
<link rel="modulepreload" href="/assets/vendor-9f3a.js" />

Los bundlers hacen esto por ti: analizan el grafo en build y, para cada punto de entrada, inyectan las etiquetas modulepreload de los chunks que sabe que hará falta, convirtiendo una serie en un paralelo. Vite lo hace de serie y añade un pequeño polyfill, porque el soporte nativo de modulepreload no es universal en todos los navegadores. La diferencia con preload a secas es que modulepreload no solo descarga: también parsea y compila el módulo y resuelve sus dependencias, dejándolo listo para ejecutar.

Para la carga anticipada por intención —cuando el usuario aún no ha pedido nada pero muestra que lo hará— está prefetch, que trae el chunk de una ruta en cuanto el cursor sobrevuela su enlace, de modo que ya esté en caché al hacer clic. La distinción de prioridad es clave: modulepreload es de alta prioridad, para lo que el arranque necesita ya; prefetch es de baja prioridad y no debe competir con los recursos críticos, para lo que quizá se necesite pronto.

La precarga tiene un límite conceptual que conviene tener claro: solo puede adelantar lo que el bundler conoce en build. Una cascada nacida de import() con literales es aplanable, porque el grafo es visible y el bundler puede emitir las etiquetas. Una cascada nacida de especificadores opacos o de decisiones puramente de runtime no lo es, porque nadie sabe en build qué se pedirá. De nuevo aparece la lección del nivel dos: mantener los cortes analizables no es solo cuestión de que el chunk exista, es lo que permite precargarlo y, con ello, disolver la cascada que ese mismo corte podría causar.

🔗

modulepreload

Para chunks que el arranque actual necesitará seguro. Los pide en paralelo y aplana la cascada.

🖱️

prefetch

Para chunks de una navegación probable. Se traen con baja prioridad ante una señal de intención.

🎯

Análisis en build

El bundler conoce el grafo y decide qué precargar por entrada, sin que tú listes chunks a mano.

Las tres formas de anticipar carga se distinguen por cuándo actúan y con qué prioridad compiten por el ancho de banda:

Mecanismo Para qué sirve Prioridad
import estático código del camino crítico inicial máxima
modulepreload chunks que el arranque sí necesitará alta
prefetch chunks de una navegación probable baja

La heurística de equilibrio

No hay un número mágico de chunks; hay un método para acercarse al equilibrio. La regla que ha cuajado en 2026 combina las lecciones de los cuatro niveles anteriores en una política sencilla.

Eje de división Cuándo dividir Por qué
Por ruta Casi siempre Frontera natural de uso; la ruta no visitada no se paga
Por componente Solo si es pesado o raro Un editor o un mapa sí; un botón, jamás
Vendor Separado del código de app Ritmo de cambio distinto, caché de larga duración
Chunks minúsculos Fundirlos El coste fijo por petición supera al ahorro

Esta tabla es un punto de partida, no una verdad revelada. El equilibrio real depende de datos que solo tienes de tus usuarios: la distribución de sus redes, sus dispositivos, sus patrones de navegación. Por eso la disciplina madura complementa las métricas de laboratorio con datos de campo —el rendimiento medido en usuarios reales— y ajusta la política de chunking a lo que esos datos revelan, no a lo que un build local sugiere sobre una red de fibra.

La síntesis: divide agresivo por ruta, selectivo por componente, agrupa lo diminuto, aísla el vendor y precarga lo que el arranque necesitará seguro. Y sobre todo, mide el resultado bajo latencia real, no en kilobytes, porque el enemigo final del splitting no es el tamaño, es el tiempo.

💡
Convierte el equilibrio en una prueba

Un equilibrio que no se vigila se degrada solo: cada dependencia nueva y cada refactor mueven las fronteras de chunk sin avisar. La defensa es automatizar en el CI un presupuesto por chunk —tamaño máximo del entry, del vendor, de cada async— y un aviso cuando el número total de chunks se dispara. Así el equilibrio deja de ser una intuición que revisas de vez en cuando y pasa a ser una restricción que el pipeline defiende en cada commit.

El splitting óptimo es un problema de equilibrio, no de maximización

La trampa conceptual del code splitting es tratarlo como una optimización monótona —si dividir un poco ayuda, dividir mucho ayudará más— cuando en realidad es un problema de equilibrio con dos costes en tensión. De un lado, el coste de enviar de más: bytes en el camino crítico que el usuario paga sin usar. Del otro, el coste de coordinar de más: peticiones, cascadas, compresión perdida, CPU de parseo repartida en cientos de módulos. Estos dos costes se mueven en direcciones opuestas conforme divides, y la función que suman tiene forma de valle: cae al principio, cuando cada corte ahorra bytes inútiles, y vuelve a subir cuando cada corte nuevo solo añade coordinación. El objetivo no es empujar ninguno de los dos ejes al máximo, sino encontrar el fondo de ese valle —y, como todo mínimo, se caracteriza porque moverse en cualquier dirección empeora—. Esto reencuadra por completo la práctica: dejas de preguntarte cuánto puedes dividir y empiezas a preguntarte dónde el coste marginal de partir un chunk más iguala al beneficio marginal de diferirlo, que es exactamente la condición de un óptimo económico. Y explica por qué la respuesta depende del producto y de la red de tus usuarios: el fondo del valle se desplaza con la latencia, con el patrón de navegación, con el ritmo de despliegue. No existe una configuración de chunking universalmente correcta porque no existe un equilibrio universal; existe el tuyo, que solo se encuentra midiendo el tiempo hasta interactivo bajo las condiciones reales de quien usa tu app. La destreza última del code splitting no es saber dividir —eso es un import()—; es saber cuándo dejar de hacerlo, que es la forma más difícil y más valiosa de saber optimizar.

⚔️ Encuentra el fondo del valle
  1. Construye a propósito una cascada de tres chunks encadenados y mide su tiempo hasta interactivo bajo throttling de red lento.
  2. Añade modulepreload para esos chunks y vuelve a medir: cuantifica cuánto tiempo recuperaste al paralelizar el descubrimiento.
  3. Cuenta los chunks de tu build y localiza los más pequeños; funde los que estén muy por debajo del umbral útil de compresión.
  4. Compara el tiempo hasta interactivo de tu versión dividida con el de un bundle monolítico bajo la misma red; verifica que dividir de verdad ganó.
  5. Escribe la política de chunking de tu app en una frase —qué divides por ruta, por componente y qué agrupas— y justifícala con tus mediciones, no con intuiciones.