Cascadas de peticiones y el patrón de isla
Cómo se forma una cascada de peticiones encadenadas, por qué el troceado la agrava, cómo se lee en el waterfall, y qué resuelve el patrón de isla que el troceado tradicional no resuelve.
El troceado tiene un efecto secundario que se paga en latencia: cada frontera es un punto donde el navegador tiene que ir a buscar algo, y si ese algo se descubre solo después de haber descargado y ejecutado lo anterior, las peticiones se encadenan en serie. Una cascada de cuatro niveles con 150 milisegundos de latencia son 600 milisegundos que no aparecen en ningún informe de bytes. Y la solución estructural a este problema no es más troceado: es cambiar el modelo, que es lo que hace el patrón de isla.
- Reconocer una cascada en el waterfall y contar sus niveles.
- Enumerar las cuatro causas habituales de encadenamiento de peticiones.
- Aplanar una cascada moviendo el descubrimiento hacia el documento.
- Explicar qué elimina el patrón de isla que el troceado por sí solo no elimina.
Anatomía de una cascada
Una cascada ocurre cuando la petición B no puede empezar hasta que la petición A ha terminado y se ha procesado. El coste no es la suma de los tamaños: es la suma de las latencias más la suma de los tiempos de procesamiento.
El ejemplo canónico en una aplicación troceada:
0 ms ├─ documento HTML ─────────┤
180 ms ├─ app.js ────────┤
420 ms ├─ ruta-articulo.js ──┤
610 ms ├─ editor.js ─┤
790 ms ├─ datos API ──┤
Cinco niveles. El último byte útil llega a los 950 milisegundos y la suma de todos los ficheros es de 90 KB, que a 1,6 Mbps se descargarían en 450 milisegundos si fueran en paralelo. Medio segundo perdido en esperas, no en bytes.
flowchart TB H[Documento HTML] -->|descubre| A[app.js] A -->|se ejecuta y pide| R[ruta-articulo.js] R -->|se ejecuta y pide| E[editor.js] E -->|se ejecuta y pide| D[Datos de la API] H -.->|con modulepreload| A2[app.js y sus dependencias en paralelo] H -.->|con enlace en el HTML| D2[Datos pedidos desde el principio] style H fill:#89b4fa,color:#11111b style A fill:#f9e2af,color:#11111b style R fill:#f9e2af,color:#11111b style E fill:#f38ba8,color:#11111b style D fill:#f38ba8,color:#11111b style A2 fill:#a6e3a1,color:#11111b style D2 fill:#a6e3a1,color:#11111b
En el panel de red la cascada tiene una forma inconfundible: una escalera descendente, donde cada barra empieza justo donde acaba la anterior. Si las barras se solapan, hay paralelismo; si forman peldaños, hay encadenamiento. Contar los peldaños te da el número de viajes de ida y vuelta en serie, y multiplicarlo por tu latencia de referencia te da el coste mínimo irreductible de esa cascada.
Las cuatro causas
Causa uno: el descubrimiento tardío por JavaScript. El navegador no sabe que necesita ruta-articulo.js hasta que app.js se ha descargado, parseado, compilado y ejecutado hasta la línea del import(). Es la causa estructural del troceado y la que el modulepreload resuelve.
Causa dos: los datos pedidos después de montar el componente. El patrón de pedir datos en un efecto que corre tras el montaje garantiza que la petición de datos empiece después de que todo el JavaScript se haya ejecutado. Es la cascada más cara de todas porque la petición de datos suele ser la más lenta.
Causa tres: las importaciones de CSS dentro de módulos. Una hoja de estilos importada desde un módulo de JavaScript no existe para el escáner de precarga: se descubre cuando el módulo se ejecuta. Y como el CSS bloquea el pintado, la cascada se traduce directamente en pantalla en blanco.
Causa cuatro: los redireccionamientos y los dominios nuevos. Cada redirección es un viaje completo. Cada dominio nuevo son entre uno y tres viajes para DNS, TCP y TLS antes del primer byte útil. Tres dominios de terceros descubiertos tarde son fácilmente 500 milisegundos.
Aplanar: mover el descubrimiento al documento
La estrategia general es siempre la misma: hacer que el navegador conozca las URL lo antes posible, idealmente en el HTML que ya está descargando.
Para los fragmentos de la ruta actual, las pistas de precarga en el documento. Es lo que hace el empaquetador si se lo pides, y elimina los niveles uno y dos de la escalera:
<link rel="modulepreload" href="/assets/app-8c21.js">
<link rel="modulepreload" href="/assets/chunks/ruta-articulo-31fa.js">
<link rel="modulepreload" href="/assets/chunks/vendor-marco-77b0.js">
Con el servidor renderizando la página, esto se genera sabiendo qué ruta se está sirviendo, y no hay especulación: son exactamente los fragmentos que van a hacer falta.
Para los datos, la técnica que más ahorra y menos se usa: pedirlos desde el propio HTML, en paralelo con el JavaScript, y que el componente recoja la promesa ya en vuelo.
<head>
<script>
// Se ejecuta antes que cualquier modulo. La peticion arranca de inmediato.
window.__datos = fetch('/api/articulo/8412', { headers: { accept: 'application/json' } })
.then((r) => r.json());
</script>
<script type="module" src="/assets/app-8c21.js"></script>
</head>
// En el componente, mas tarde: la promesa ya lleva 400 ms en vuelo
const articulo = await window.__datos;
Es un script en línea y bloqueante, lo cual normalmente sería un antipatrón; aquí está justificado porque son tres líneas que se ejecutan en microsegundos y adelantan la petición más lenta de la página. La alternativa más limpia es que el servidor incruste los datos directamente en el HTML, con lo que la petición desaparece del todo.
Para los dominios de terceros, el preconectado en el documento resuelve los viajes de establecimiento antes de que se necesiten:
<link rel="preconnect" href="https://api.midominio.com" crossorigin>
<link rel="dns-prefetch" href="https://cdn-terceros.example">
Con moderación: cada preconnect abre una conexión que consume recursos, y más de tres o cuatro empieza a ser contraproducente.
El patrón de isla
Todo lo anterior optimiza el modelo de «una aplicación de JavaScript que se carga y toma el control de la página». El patrón de isla cambia el modelo.
La idea: la página se entrega como HTML completo y funcional, y solo los trozos que de verdad necesitan interactividad se hidratan con JavaScript, cada uno por su cuenta y de forma independiente. Una página de artículo con una barra de navegación desplegable, un botón de compartir y un formulario de comentarios tiene tres islas; el resto de la página es HTML estático que no requiere ni un byte de JavaScript.
Lo que esto elimina, y que el troceado por sí solo no puede eliminar:
La dependencia entre el contenido y el JavaScript. En una aplicación de cliente, el contenido no existe hasta que el JavaScript se ejecuta: la cascada afecta a lo que el usuario ve. Con islas, el contenido está en el HTML y se pinta con la primera pasada. El LCP deja de depender del JavaScript por completo.
La sincronización global. Una aplicación monolítica tiene que hidratarse entera antes de responder a nada. Con islas, cada una se hidrata cuando toca, y una isla lenta no bloquea a las demás.
El código común obligatorio. Cada isla carga solo lo suyo. No hay un chunk común que todos paguen.
La expresión más habitual del patrón declara la estrategia de carga por isla:
<!-- La barra de navegacion se hidrata en cuanto se pueda -->
<nav-desplegable client:load></nav-desplegable>
<!-- El boton de compartir, cuando el navegador este ocioso -->
<boton-compartir client:idle></boton-compartir>
<!-- Los comentarios, solo cuando se acerquen al viewport -->
<hilo-comentarios client:visible></hilo-comentarios>
<!-- El buscador avanzado, solo en pantallas donde se muestra -->
<busqueda-avanzada client:media="(min-width: 900px)"></busqueda-avanzada>
Esas cuatro estrategias —inmediata, en tiempo ocioso, por visibilidad y por consulta de medios— cubren prácticamente todos los casos, y la última es especialmente valiosa porque evita descargar en móvil el código de un componente que en móvil no se muestra, algo que ningún troceado por ruta consigue.
El patrón no es exclusivo de un marco concreto: se puede implementar a mano con elementos personalizados y un observador, y la versión mínima cabe en veinte líneas:
// Islas a mano: cada elemento declara su modulo y su estrategia
for (const el of document.querySelectorAll('[data-isla]')) {
const cargar = () => import(/* @vite-ignore */ el.dataset.isla)
.then((m) => m.hidratar(el));
switch (el.dataset.estrategia) {
case 'idle':
'requestIdleCallback' in window
? requestIdleCallback(cargar, { timeout: 2000 })
: setTimeout(cargar, 1200);
break;
case 'visible':
new IntersectionObserver((es, o) => {
if (!es[0].isIntersecting) return;
o.disconnect();
cargar();
}, { rootMargin: '200px' }).observe(el);
break;
default:
cargar();
}
}
El entusiasmo por las islas suele omitir lo que se paga, y conviene saberlo antes de rediseñar una aplicación entera.
Compartir estado entre islas es un problema que en una aplicación monolítica no existe. Dos islas que necesitan el mismo dato —el contador del carrito en la barra y el botón de añadir en la ficha— no comparten un árbol de componentes ni un contexto. Las salidas son un almacén global fuera de los marcos, eventos personalizados en el documento, o directamente atributos del DOM. Todas funcionan y todas son más artesanales que un contexto de componente. Si tu interfaz tiene mucho estado cruzado, las islas te van a costar más de lo que ahorran.
Cada isla paga su propio arranque de marco. Cinco islas del mismo marco cargan el tiempo de ejecución una vez —está compartido— pero ejecutan cinco arranques, cinco montajes, cinco árboles. En páginas con veinte islas pequeñas, la suma de arranques puede superar al monolito que sustituía. El punto óptimo está en pocas islas grandes, no en muchas pequeñas, que es exactamente lo contrario de lo que la intuición sugiere.
Y el coste que nadie anticipa: la coordinación temporal. Con islas independientes, el orden en que se hidratan no está garantizado. Una isla que espera un evento que otra emitió antes de que ella existiera se queda sin enterarse. Ese fallo es intermitente, depende de la velocidad de la red y del dispositivo, y es muy difícil de reproducir en desarrollo, donde todo carga de golpe. La cura es que la comunicación entre islas sea por estado observable con valor actual, no por eventos: quien llega tarde lee el valor en lugar de haberse perdido el mensaje.
// Almacen minimo compartido entre islas: quien llega tarde ve el valor actual
export function crearSenal(inicial) {
let valor = inicial;
const oyentes = new Set();
return {
get: () => valor,
set(v) { valor = v; for (const f of oyentes) f(v); },
suscribir(f) { f(valor); oyentes.add(f); return () => oyentes.delete(f); },
};
}Fíjate en que suscribir llama al oyente inmediatamente con el valor actual antes de registrarlo. Esa línea es la diferencia entre un sistema de islas que funciona y uno con fallos que solo aparecen en móviles lentos.
Abre el panel de red de tu aplicación en la ruta más pesada, con la red estrangulada, y cuenta los peldaños de la escalera desde el documento hasta el último recurso necesario para pintar el contenido principal. Multiplica ese número por tu latencia de referencia: ese es el suelo de tu tiempo de carga. Después identifica cuál de las cuatro causas produce cada peldaño y elimina el más profundo, que casi siempre es la petición de datos.