Qué es el lazy loading y por qué importa
El código que no se ejecuta al arrancar no debería descargarse ni evaluarse al arrancar. El lazy loading difiere la carga de lo no crítico hasta que se necesita y recorta el JavaScript inicial que bloquea el hilo principal. Qué es un punto de corte, por qué el coste del JS es el parseo y no solo la red, y cómo separar lo crítico de lo diferible.
Un usuario abre tu aplicación para leer un artículo y, sin saberlo, descarga y ejecuta el editor de texto enriquecido, el visor de gráficas, el selector de emojis y las tres rutas que quizá nunca visite. Todo eso viaja en el mismo bundle inicial, se parsea y se compila antes de que aparezca la primera letra. El lazy loading es la disciplina de no hacer eso: enviar al arranque solo lo que la primera pantalla necesita y diferir el resto hasta el instante exacto en que se use. No es una micro-optimización; es decidir a conciencia cuándo paga cada byte de JavaScript su coste.
- Distinguir la carga ansiosa de la diferida y qué mueve cada una.
- Entender por qué el coste del JavaScript es el hilo principal, no solo la red.
- Reconocer el
import()dinámico como el punto de corte que crea un chunk. - Aprender a trazar la frontera entre lo crítico y lo diferible sin romper la primera pantalla.
El coste que no ves: el hilo principal
El error mental más común es pensar que el coste del JavaScript es su descarga. La red es solo la primera mitad. Cuando el navegador recibe un archivo .js, tiene que parsearlo a un árbol de sintaxis, compilarlo a bytecode y ejecutarlo, y todo eso ocurre en el hilo principal: el mismo hilo, único, que pinta la interfaz y responde a los clics. Mientras ese hilo procesa JavaScript, la página no responde. Nada más.
Aquí está la asimetría que lo cambia todo. Una imagen de 300 KB se decodifica en un hilo aparte y no bloquea la interacción; 300 KB de JavaScript se parsean y ejecutan en el hilo principal y sí la bloquean. Byte por byte, el JavaScript es con diferencia el recurso más caro de la web, porque su coste no termina cuando llega, sino cuando el CPU ha terminado de digerirlo. Y en el teléfono de gama media que usa la mayoría del mundo, ese CPU es varias veces más lento que el de tu portátil de desarrollo, así que el coste que a ti te parece despreciable a tu usuario le cuesta segundos de pantalla congelada.
No todos los kilobytes pesan igual, y conviene tener grabada la jerarquía. Un mismo tamaño en disco reclama trabajos muy distintos del hilo principal según el tipo de recurso, y esa es la razón última de que el JavaScript sea el que hay que vigilar con más celo.
| Recurso | Coste de red | Coste en el hilo principal |
|---|---|---|
| Imagen | proporcional al peso | decodificación fuera del hilo |
| CSS | proporcional al peso | cálculo de estilo, acotado |
| Fuente | proporcional al peso | casi nulo tras la descarga |
| JavaScript | proporcional al peso | parseo, compilación y ejecución en el hilo |
Las métricas de 2026 miden justo esto. El TBT —Total Blocking Time— suma en el laboratorio los milisegundos en que el hilo estuvo bloqueado durante la carga. El INP —Interaction to Next Paint, el Core Web Vital que reemplazó al FID en 2024— mide en producción cuánto tarda la interfaz en responder a una interacción real. Ambos empeoran con cada script que evalúas de más al arrancar. El lazy loading es la palanca más directa que tienes sobre ellos: menos JavaScript inicial es, casi mecánicamente, un hilo principal más libre.
Este cálculo pesa aún más en 2026, cuando el listón lo fija INP y no una métrica de carga pura. Ya no basta con pintar rápido: la página tiene que responder rápido, y responder es exactamente lo que el hilo principal no puede hacer mientras digiere JavaScript de arranque. Cada arquitectura nueva —islas, componentes de servidor, hidratación selectiva— es, en el fondo, una forma distinta de reducir ese JavaScript inicial, y el lazy loading es la pieza que cualquiera de ellas usa para diferir lo que sí corre en el cliente.
Ansioso o diferido: dos formas de tratar el grafo
Un import estático en la cima de un módulo es una declaración de que ese código forma parte del grafo desde el primer instante. El bundler lo sigue, lo incluye en el bundle inicial y el navegador lo evalúa al arrancar, lo uses o no en la primera pantalla. Eso es carga ansiosa: todo el grafo alcanzable estáticamente entra de una vez.
La carga diferida rompe esa cadena en un punto elegido. En lugar de decir “este código es parte del arranque”, dices “este código lo pediré cuando haga falta”. El bundler entiende esa intención y, en vez de fundir el módulo en el bundle inicial, lo emite como un chunk aparte que solo se descarga bajo demanda.
flowchart TD subgraph Carga ansiosa A[Baja un bundle enorme] --> B[Parseo y compilacion bloquean el hilo] B --> C[La pagina tarda en responder] end subgraph Carga diferida D[Baja solo lo critico] --> E[Hilo principal libre antes] E --> F[El resto llega cuando se necesita] end style C fill:#f38ba8,color:#11111b style F fill:#a6e3a1,color:#11111b
La ganancia no es que el usuario descargue menos en total a lo largo de la sesión —puede que acabe bajando lo mismo—, sino que descarga y ejecuta más tarde todo lo que no necesita ahora. El arranque, que es el momento donde el usuario juzga si tu aplicación es rápida o lenta, se vuelve barato. El coste se reparte a lo largo de la interacción, cuando el usuario ya está dentro y el hilo tiene huecos ociosos que antes desperdiciabas.
Nada de esto obliga a elegir de forma global. Una misma aplicación mezcla los dos regímenes a voluntad: el shell y la ruta actual, ansiosos; el editor, el modal y las rutas vecinas, diferidos. El arte no está en decretar una política única, sino en clasificar cada módulo por su momento de uso y colocar el punto de corte justo donde ese momento se separa del arranque.
El bundler no adivina qué diferir: divide donde tú escribes import(). Esa es la buena noticia y la responsabilidad a la vez. El grafo de chunks refleja exactamente las fronteras que hayas trazado, ni una más ni una menos, así que la calidad de tu lazy loading es la calidad de tus decisiones sobre dónde colocar esos cortes.
El punto de corte: el import dinámico
El mecanismo que expresa “esto es diferible” es una única construcción del lenguaje: import() como función. A diferencia del import estático, que es una declaración, import() es una expresión que devuelve una promesa que resuelve al módulo. Ese es el punto de corte, y el bundler lo trata como tal: donde ve un import(), corta el grafo y genera un chunk independiente.
// Import estatico: entra en el bundle inicial, se evalua al arrancar
import { editorPesado } from "./editor";
// Import dinamico: punto de corte, un chunk aparte que baja bajo demanda
async function abrirEditor() {
const { editorPesado } = await import("./editor");
editorPesado();
}
La diferencia visible es mínima —un par de paréntesis y un await— pero la consecuencia en el grafo es total. En el primer caso, ./editor y todo lo que él importe viajan en el arranque. En el segundo, nada de ese subárbol se descarga hasta que se llama a abrirEditor. El bundler resuelve estáticamente el especificador para saber qué chunk preparar, pero pospone su entrega. Es la misma primitiva sobre la que se construye todo lo demás en este nivel: dividir por ruta, por componente, cargar bajo interacción. Todo son estrategias sobre dónde colocar el import().
Merece la pena detenerse en que import() devuelve una promesa, no el módulo. Eso obliga a tratar la carga como una operación asíncrona —con su estado intermedio y su posible rechazo— y esa naturaleza es la que, más adelante, los frameworks envuelven en un límite declarativo para que no tengas que orquestarla a mano. Por ahora basta con retener la forma: pides, esperas, recibes.
Para cortar un chunk, el bundler tiene que poder resolver estáticamente a qué apunta el import(). Un import("./editor") con ruta literal se divide sin problema; un import(variable) con ruta calculada en tiempo de ejecución no siempre, porque la herramienta no sabe de antemano qué archivo preparar. La regla práctica es mantener el especificador lo bastante literal como para que el bundler lo entienda y pueda emitir el chunk.
Dónde trazar la frontera
Diferir no es gratis: cada punto de corte introduce una espera potencial en el momento del uso. Por eso la pregunta no es “qué puedo diferir”, sino “qué conviene diferir”. La frontera correcta separa lo que la primera pantalla necesita para ser útil de lo que solo hará falta más tarde o quizá nunca.
Crítico: no lo difieras
El shell de la app, la ruta actual, lo visible sin desplazarse y la primera interacción esperada. Diferirlo solo añade un spinner donde debería haber contenido.
Diferible: córtalo
Modales, rutas no visitadas, editores pesados, gráficas bajo el pliegue, código que depende de una acción del usuario que quizá no ocurra.
Poner un import() delante de algo que se ve de inmediato empeora la experiencia: cambias un poco de JavaScript inicial por una petición de red en el momento del render, y el usuario ve un hueco vacío o un salto de layout. La regla es tozuda: lo que está en el camino crítico se carga ansioso; el lazy loading es para lo que sale de él.
Diferir reparte el cuándo, no borra el cuánto
Un malentendido tenaz es creer que el lazy loading reduce el trabajo total. No lo hace: si el usuario acaba abriendo el editor, descargará y ejecutará su código igual que si hubiera viajado en el arranque. Lo que cambia es el momento, y el momento lo es todo, porque el arranque es cuando el usuario decide si tu aplicación merece su paciencia. Diferir mueve el coste desde ese instante crítico hasta un hueco posterior donde el usuario ya está comprometido y el hilo tiene tiempo libre.
Es contabilidad temporal, no ahorro neto. El ahorro neto —enviar menos código en total— lo dan otras dos técnicas que este track distingue con cuidado: el tree shaking, que borra el código muerto que nadie alcanza, y el renderizado en el servidor, que evita mandar al cliente código que puede correr antes de llegar. El lazy loading no compite con ellas; las complementa ocupándose de una pregunta distinta, la del instante.
¿Enviarlo? · Tree shaking
Borra el código muerto que nadie alcanza. El ahorro más puro: lo que no se usa no llega a existir en el bundle.
¿Dónde? · Servidor
Los Server Components y el SSR ejecutan código antes de llegar al cliente, que nunca lo descarga ni lo evalúa.
¿Cuándo? · Lazy loading
No cambia si el código existe ni dónde corre, solo el instante en que paga su descarga y su evaluación.
El presupuesto de arranque
Traducir todo esto a la práctica pide una cifra, no una intuición. Un presupuesto de arranque fija cuántos kilobytes de JavaScript puede pesar la primera carga —un número que eliges según tu público y sus dispositivos— y convierte la vaga aspiración de “ir ligero” en una restricción verificable. Sin presupuesto, el bundle crece a hurtadillas, un import estático inocente cada semana, hasta que un día el arranque se siente lento y nadie sabe cuándo se rompió.
El presupuesto es también lo que decide, en cada duda, si un módulo entra ansioso o diferido: si añadirlo al arranque rebasa el número, se difiere; si cabe, puede quedarse. Herramientas de análisis de bundle y comprobaciones en el CI hacen cumplir ese límite automáticamente, fallando la build cuando se supera, de modo que la disciplina no dependa de que alguien se acuerde de medir.
Conviene situar el lazy loading en su lugar exacto dentro de la jerarquía de no gastar. Hay tres preguntas distintas sobre cada trozo de código, y confundirlas es la raíz de casi todos los bundles obesos. La primera es si enviarlo: el tree shaking elimina el código muerto que nadie alcanza, y no hay optimización más pura que borrar lo que no se usa. La segunda es dónde ejecutarlo: los Server Components de React y el renderizado en el servidor mueven trabajo fuera del cliente, de modo que cierto código nunca cruza la red hacia el navegador. La tercera, y solo la tercera, es cuándo enviarlo: eso es el lazy loading. No decide si un código existe ni dónde corre, sino en qué instante paga su coste de descarga y evaluación. Su valor nace de una verdad incómoda del rendimiento web: el recurso escaso no es el ancho de banda, es el hilo principal, ese único carril por el que pasan a la vez el pintado, la respuesta a los clics y la ejecución de todo tu JavaScript. Cada script que evalúas al arrancar es tiempo robado a la interacción, y la ley de Amdahl se aplica sin piedad: por muy rápida que sea tu red, el arranque no puede ir más deprisa que la parte serial que el CPU debe procesar antes de responder. El lazy loading ataca precisamente esa parte serial. Recorta lo que el hilo tiene que digerir antes de estar disponible, y difiere el resto a los huecos ociosos que aparecen cuando el usuario ya está dentro, leyendo, decidiendo su próximo clic. Interiorizar esto cambia cómo lees cada import: no como una línea neutra, sino como una decisión sobre cuándo, y a costa de qué, ese código reclama el único recurso que de verdad no puedes multiplicar.
- Abre tu aplicación en las herramientas del navegador, ve a la pestaña de red y anota cuántos KB de JavaScript se descargan antes del primer render útil.
- En el panel de rendimiento, graba la carga y localiza las tareas largas que bloquean el hilo principal: cada bloque ancho es JavaScript evaluándose al arrancar.
- Identifica un componente pesado que no se ve en la primera pantalla —un editor, un mapa, un modal— y confirma que hoy viaja en el bundle inicial.
- Reescribe su
importestático como unimport()dinámico y vuelve a medir los KB iniciales y elTBT; contrasta la cifra con tu línea base. - Razona para cada import de tu punto de entrada si es crítico o diferible, y justifica por qué mover un caso al lazy loading mejoraría o empeoraría la primera pantalla.
- Activa una CPU ralentizada 4x en el panel de rendimiento y repite la medición: así ves lo que siente el móvil de gama media que ejecuta tu bundle.