wandres.dev
LAZY LOADING · cargar bajo demanda

El equilibrio: chunks, layout shift y UX de carga

El lazy loading tiene un punto óptimo y dos abismos a los lados: un bundle monolítico con demasiado JS inicial, y mil chunks diminutos con cascadas de peticiones y peor compresión. Encontrar el tamaño justo, reservar el hueco para evitar el salto de layout, y diseñar una espera que se sienta rápida son las tres decisiones que separan un lazy loading que ayuda de uno que estorba.

⏱ 14 min

Dividir es bueno, pero dividir sin medida no lo es. Cada punto de corte que añades resuelve un problema —menos JavaScript inicial— y crea otro más pequeño: una petición más, un poco menos de compresión, una espera potencial. Lleva la división al extremo y reencuentras, por el otro lado, la lentitud que querías evitar, ahora disfrazada de cascada de peticiones diminutas. El lazy loading maduro no es cortar todo lo posible, sino cortar en los sitios correctos y con el grano correcto, y después cuidar los dos detalles que deciden cómo se percibe: que el contenido no salte al llegar y que la espera se sienta breve.

🎯 Al terminar esta lección sabrás
  • Situar el tamaño de chunk óptimo entre el monolito y la fragmentación extrema.
  • Agrupar dependencias estables y fijar mínimos de tamaño con la config del bundler.
  • Reservar el hueco de un componente diferido para eliminar el salto de layout.
  • Diseñar una espera que se perciba rápida: esqueletos, spinners con retardo y streaming.

Ni un chunk ni mil: el tamaño justo

El monolito es el defecto que el lazy loading combate: un solo archivo con todo, mucho JavaScript que bloquea el arranque. Pero el extremo opuesto tiene su propia patología. Mil chunks minúsculos multiplican el coste por petición —cada uno con su cabecera, su resolución, su lugar en la cadena—, comprimen peor porque gzip y brotli rinden más cuanto más grande y repetitivo es el archivo, y reintroducen cascadas cuando un chunk pequeño importa a otro que importa a otro.

flowchart LR
A[Un chunk gigante] --> B[Mucho JS inicial y TBT alto]
C[Mil chunks minusculos] --> D[Cascada de peticiones y peor compresion]
E[Chunks del tamano justo] --> F[Equilibrio de red y ejecucion]
style B fill:#f38ba8,color:#11111b
style D fill:#fab387,color:#11111b
style F fill:#a6e3a1,color:#11111b

Conviene desmontar el consuelo de que HTTP/2 y HTTP/3 vuelven gratis las peticiones. El multiplexado abarata abrir conexiones, pero no elimina el coste por recurso: cada chunk sigue teniendo su descompresión, su compilación y su posición en el grafo, que el navegador no puede pedir hasta leer el módulo que lo importa. La red mejoró; la aritmética del grano no desapareció.

Tampoco existe un número mágico de kilobytes por chunk. El tamaño correcto depende del uso: un chunk que casi todos los usuarios necesitan pronto quiere ser grande y estar cacheado; uno que solo abre una minoría quiere ser pequeño y diferido. La pregunta no es “cuántos KB”, sino “cuánta gente y cuándo”, y por eso el splitting por ruta y por componente —que cortan según el uso real— produce por sí solo un grano bastante razonable antes de que toques nada.

Un dato concreto sobre la compresión ayuda a interiorizar el extremo malo. Brotli y gzip construyen su diccionario a partir de lo que ya vieron dentro del mismo archivo, así que un artefacto grande y repetitivo se comprime proporcionalmente mejor que diez fragmentos que no comparten contexto entre sí. Partir un chunk en diez no solo multiplica las cabeceras: también le quita al compresor el material del que sacaba su ventaja. Por eso mil chunks diminutos pesan, sumados, más que el bundle del que salieron.

Aspecto Bundle monolítico Mil chunks diminutos Grano justo
JS inicial excesivo mínimo el necesario
Peticiones una cientos en cascada pocas y paralelas
Compresión óptima pobre buena
Riesgo arranque lento overhead y waterfalls equilibrio

Controlar el grano con el bundler

El bundler decide cómo agrupar módulos en chunks, y le puedes guiar. El caso más rentable es sacar las dependencias estables —el runtime del framework, librerías que casi nunca cambian— a un chunk de vendor propio: como su hash no varía entre despliegues, el navegador lo cachea una vez y lo reutiliza aunque tu código cambie a diario.

// vite.config: agrupar dependencias estables en un chunk de vendor cacheable
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ["react", "react-dom"],
        },
      },
    },
  },
});

Contra la fragmentación excesiva, Rollup y Rolldown ofrecen un mínimo de tamaño de chunk: por debajo de ese umbral, el bundler fusiona trozos vecinos en lugar de emitir archivos ridículamente pequeños. Es la defensa automática contra el “mil chunks”, y suele bastar con dejarla en su valor por defecto y confiar en que el splitting por ruta y por componente produzca un grano razonable.

Chunks compartidos y duplicación

Hay un peligro sutil en dividir mucho: la duplicación. Si dos rutas diferidas importan la misma librería y el bundler no la reconoce como común, esa librería puede acabar copiada dentro de ambos chunks, y el usuario que visita las dos la descarga dos veces. Los bundlers modernos detectan esos módulos compartidos y los extraen a un chunk común que ambas rutas referencian, pero conviene verificarlo, porque una mala configuración de manualChunks puede romper esa extracción y reintroducir el peso por partida doble.

La regla mental es que un módulo usado por varios chunks quiere vivir en un chunk propio, cargado una vez y compartido, no replicado. Cuando el visualizador de bundle te muestre la misma dependencia dentro de dos chunks distintos, has encontrado kilobytes que se descargan de más sin que nadie los pidiera dos veces a propósito.

El salto de layout: reserva el hueco

Un componente diferido llega tarde por definición, y cuando llega, ocupa espacio. Si ese espacio no estaba reservado, todo lo que hay debajo se desplaza de golpe: es el Cumulative Layout Shift, el Core Web Vital que castiga precisamente esos saltos. El usuario que iba a pulsar un botón lo ve saltar y pulsa otra cosa. Un lazy loading que provoca CLS ha cambiado un problema de velocidad por uno de estabilidad, y el segundo se siente peor.

La cura es reservar el hueco antes de llenarlo. El fallback del Suspense no debe ser un spinner suelto flotando en el vacío, sino un esqueleto con las dimensiones exactas del contenido que vendrá, de modo que la llegada del chunk no mueva un solo píxel.

/* Reservar el hueco: el esqueleto ocupa lo mismo que la grafica final */
.esqueleto-grafica {
  aspect-ratio: 16 / 9;
  min-height: 320px;
}

Propiedades como aspect-ratio, min-height o content-visibility con un contain-intrinsic-size sirven para declarar de antemano el tamaño de lo que aún no existe. La regla es simple: el fallback y el contenido final deben ocupar el mismo rectángulo. Si lo consigues, el usuario percibe una transición suave de esqueleto a contenido en lugar de un brinco. Y no olvides el caso del error: si el chunk falla y el fallback desaparece dejando un hueco vacío, también hay salto; el estado de error debería ocupar el mismo rectángulo que el de carga.

La UX de la espera

La última capa es psicológica. Una espera bien diseñada se siente más corta que una mal diseñada de igual duración. La primera decisión es retrasar el spinner: si el chunk llega en 100 ms, mostrar y ocultar un spinner produce un parpadeo que se percibe como fallo; espera 200 ms antes de mostrarlo y las cargas rápidas se sienten instantáneas.

// No mostrar el indicador si la carga es casi instantanea
const [mostrar, setMostrar] = useState(false);
useEffect(() => {
  const t = setTimeout(() => setMostrar(true), 200);
  return () => clearTimeout(t);
}, []);

Elegir la forma de la espera es la segunda decisión, y hay un pequeño repertorio con usos distintos según cuánto sepas de lo que viene y cuánto tarde.

🦴

Esqueleto

Para contenido con forma reconocible. Comunica qué llegará y reserva su hueco a la vez, matando el salto de layout de paso.

Spinner con retardo

Para esperas cortas y sin forma previsible. Con umbral de 200 ms, las cargas rápidas ni lo muestran.

🌊

Streaming SSR

La cáscara llega primero y las regiones pesadas se rellenan en cuanto están listas, sin bloquear la pintura del resto.

UI optimista

Cuando el resultado es casi seguro, muéstralo ya y reconcilia después. La mejor espera es la que el usuario no ve.

El streaming de SSR con Suspense, que React 19 consolidó, es la herramienta más potente de esta lista: permite enviar el HTML por partes, de modo que la interfaz aparece por tramos en lugar de esperar a que todo esté listo. Combinado con la hidratación selectiva, el usuario ve y usa la parte crítica mientras las regiones diferidas siguen llegando.

El tiempo percibido no es el real

La percepción de la espera obedece a reglas propias que no coinciden con el cronómetro. Una barra que progresa se siente más corta que un spinner indeterminado de igual duración, porque comunica avance; un esqueleto que insinúa la forma final se siente más corto que un vacío, porque da al ojo algo que anticipar. La misma cantidad de milisegundos puede vivirse como fluida o como rota según lo que muestres en ellos.

De ahí que la última milla del lazy loading sea de diseño y no de red. Ya recortaste el JavaScript inicial, ya adelantaste las descargas; lo que queda es que los huecos inevitables —los que ninguna precarga alcanzó a cubrir— se sientan intencionados en vez de accidentales. Un fallback pensado, con las dimensiones correctas y una transición suave, convierte una espera técnica en un momento de la interfaz que el usuario apenas registra.

💡
Mide el grano, no lo adivines

No optimices chunks a ciegas. Un visualizador de bundle —rollup-plugin-visualizer o el análisis integrado de Vite— dibuja el tamaño real de cada chunk y qué módulos lo llenan. Ahí ves de un vistazo si una dependencia gorda se coló en el chunk de entrada, si un chunk está sospechosamente vacío, o si dos rutas duplican una librería que deberían compartir. La intuición miente sobre tamaños; el mapa no. Fija además un presupuesto de JavaScript inicial y haz que el CI falle si lo superas, para que el peso no crezca a hurtadillas commit a commit.

El lazy loading es un presupuesto que se administra, no un interruptor que se enciende

Al terminar este nivel conviene recoger su tesis en una sola idea: dividir no es una virtud que se maximiza, sino un recurso que se administra. Cada corte compra menos JavaScript inicial al precio de una petición, un poco de compresión y una espera potencial, y como en toda economía real, gastar sin criterio te empobrece por el lado que no mirabas. El principiante descubre el import() y lo esparce por todo el código convencido de que más granularidad es siempre mejor, y acaba con una aplicación que arranca ligera pero tartamudea en cada interacción, ahogada en micro-descargas y saltos de layout. El experto hace lo contrario: divide poco y en los sitios correctos —por ruta, que es la junta natural; por los pocos componentes pesados que de verdad pesan— y deja el resto junto, porque sabe que un chunk de tamaño decente se descarga, se comprime y se ejecuta mejor que diez fragmentos. Y una vez trazados esos cortes, invierte su energía no en trazar más, sino en las dos cosas que deciden cómo se percibe todo: adelantar la descarga a los huecos ociosos para que la latencia no se note, y reservar el hueco visual para que la llegada no salte. Fíjate en la simetría con la que abre el track entero: allí la lección era que el build más rápido es el que no ocurre; aquí es que el chunk mejor cargado es el que ya estaba listo cuando lo pediste y aterriza sin mover un píxel. Ambas son la misma sabiduría vista dos veces —esconde el coste en el tiempo muerto, no lo elimines fingiendo que no existe— y ambas exigen medir en lugar de suponer, porque la intuición sobre tamaños, prioridades y percepción es notoriamente mala. El lazy loading maduro no se ve: el usuario nunca sabe que la mitad de tu aplicación no se había descargado cuando entró, y ese silencio, esa ausencia total de fricción, es exactamente el objetivo. Un lazy loading que se nota es un lazy loading que aún no está terminado.

⚔️ Afina el grano y la percepción
  1. Genera un visualizador de tu bundle y localiza el chunk más grande y el más pequeño; razona si alguno pide fusionarse o dividirse.
  2. Saca el runtime de tu framework a un chunk de vendor con manualChunks y confirma que su hash sobrevive a un cambio en tu código.
  3. Busca una dependencia duplicada en dos chunks distintos y ajústala para que se extraiga a un chunk común compartido.
  4. Mide el CLS de una pantalla con un componente diferido; sustituye su spinner por un esqueleto de dimensiones exactas y vuelve a medir.
  5. Añade el retardo de 200 ms al indicador de carga y compara la sensación de una carga rápida con y sin ese umbral.
  6. Fija un presupuesto de JavaScript inicial para tu proyecto y ajusta tus cortes hasta cumplirlo sin caer en la fragmentación que reintroduce cascadas.