wandres.dev
PATRONES DE DATA FETCHING · cache, dedup, prefetch

Waterfalls: detectarlos y disparar en paralelo

El anti-patrón que arruina la latencia y cómo erradicarlo. Qué es un waterfall de peticiones —cargas dependientes encadenadas en serie—, cómo detectarlo por su forma de escalera en la pestaña de red, cómo el `preload` de la ruta dispara todas las queries en paralelo antes de renderizar y cómo `query` deduplica para que las lecturas anidadas reusen lo ya en vuelo, cuándo `Promise.all` resuelve dependencias falsas, y por qué una dependencia real se minimiza pero no se elimina.

⏱ 17 min

Un waterfall es el enemigo silencioso de la latencia percibida: peticiones que podrían viajar juntas y en cambio esperan una a otra, encadenadas en serie por accidente. El componente monta, pide un dato, y solo cuando llega pide el siguiente, que no dependía del primero; o la ruta descarga su código, y solo entonces empieza a pedir datos. Cada eslabón suma su latencia a la del anterior, y el usuario espera la suma de todas. La cura es estructural: disparar las peticiones desde el preload de la ruta para que salgan en paralelo, apoyarse en query para que las lecturas anidadas reusen lo que ya está en vuelo, y reservar la secuencia solo para las dependencias que de verdad lo son.

🎯 Al terminar esta lección sabrás
  • Reconocer un waterfall: cargas encadenadas en serie que podrían ir en paralelo.
  • Detectarlo por su forma de escalera en la pestaña de red del navegador.
  • Disparar peticiones en paralelo desde el preload de la ruta y dejar que query deduplique.
  • Distinguir una dependencia falsa —resuelta con paralelismo— de una real, que se minimiza pero permanece.

Qué es un waterfall

Hay dos formas canónicas. La primera es el waterfall de datos: dos peticiones independientes que se esperan porque las escribiste en serie. La segunda es el waterfall de código y datos: la ruta espera a descargar su JavaScript antes de empezar a pedir. En ambos casos, tiempos que podían solaparse se suman.

// WATERFALL de datos: posts NO depende de perfil, pero lo espera
async function cargar(id: string) {
  const perfil = await getPerfil(id);   // primero esto...
  const posts = await getPosts(id);      // ...y solo despues esto
  return { perfil, posts };              // latencia = perfil + posts
}

Nada obliga a que posts espere a perfil: ambos dependen solo de id, que ya tienes. La estructura del código —dos await seguidos— ha inventado una dependencia que no existe, y el usuario paga la suma de dos latencias donde debería pagar la mayor de las dos.

Detectarlos: la forma de escalera

Un waterfall tiene una firma visual inconfundible en la pestaña de red: las barras de las peticiones se dibujan escalonadas, cada una empezando donde la anterior terminó, como peldaños que bajan hacia la derecha. Peticiones paralelas, en cambio, arrancan alineadas a la izquierda y se solapan en el tiempo.

// Escalera (malo):        Paralelo (bueno):
// perfil  [====]          perfil  [====]
// posts        [====]     posts   [====]
// avatar            [==]  avatar  [==]

La regla de lectura es directa: si ves peldaños y las peticiones de abajo no dependían de las de arriba, has encontrado un waterfall accidental. El objetivo es aplanar esos peldaños hasta alinearlos a la izquierda.

Disparar en paralelo desde la ruta

La palanca estructural es el preload de la ruta. Ahí invocas todas las queries que la pantalla necesitará sin esperarlas: al no haber await, salen todas a la vez, en paralelo, en cuanto hay intención de navegar. Los componentes las leen luego con createAsync, y query garantiza que esa lectura recoja la petición ya en vuelo en vez de lanzar una nueva.

// routes/usuarios/[id].tsx
export const route = {
  preload: ({ params }) => {
    getPerfil(params.id);  // se disparan
    getPosts(params.id);   // las dos
    getAvatar(params.id);  // a la vez
  },
} satisfies RouteDefinition;

Este es el porqué de que query y preload no fueran dos temas sueltos sino uno solo. La deduplicación por clave es lo que permite disparar en la ruta y leer en cualquier componente anidado sin duplicar: no importa a qué profundidad del árbol un componente pida getPosts(id), resolverá a la misma clave que el preload ya calentó. El paralelismo se declara arriba, en la ruta; la lectura se reparte abajo, en los componentes; y la clave los cose sin una sola petición de más.

flowchart LR
subgraph Serie [waterfall en serie]
  direction TB
  P1[perfil] --> P2[posts] --> P3[avatar]
end
subgraph Paralelo [preload dispara a la vez]
  direction TB
  R[preload de ruta] --> Q1[perfil]
  R --> Q2[posts]
  R --> Q3[avatar]
end
Serie --> L1[latencia = suma]
Paralelo --> L2[latencia = la mayor]
style Serie fill:#f38ba8,color:#11111b
style Paralelo fill:#a6e3a1,color:#11111b

Cuando la latencia manda dentro de una misma función de servidor, Promise.all expresa el paralelismo de forma explícita: lanzas las promesas y esperas a todas juntas, no una tras otra.

async function cargar(id: string) {
  const [perfil, posts] = await Promise.all([getPerfil(id), getPosts(id)]);
  return { perfil, posts }; // latencia = la mayor, no la suma
}

Hay una tercera fuente de waterfalls, más sigilosa, en la colocación de los Suspense. Dos createAsync hermanos bajo un mismo límite de Suspense disparan sus peticiones a la vez y revelan juntos; pero si anidas un Suspense dentro de otro, el interior no empieza a montar —ni a pedir— hasta que el exterior resuelve, y has vuelto a serializar. La regla práctica es agrupar bajo un solo límite lo que puede cargar en paralelo, y anidar límites solo cuando de verdad quieras que una sección espere a otra.

// Paralelo: ambos createAsync corren bajo el MISMO Suspense
<Suspense fallback={<Cargando />}>
  <Perfil id={id} />   {/* getPerfil */}
  <Posts id={id} />    {/* getPosts, a la vez que getPerfil */}
</Suspense>

Cuando la dependencia es real

No todo encadenamiento es un pecado. A veces la segunda petición necesita el resultado de la primera: pides un pedido para conocer el id de su cliente, y solo entonces puedes pedir ese cliente. Esa dependencia es real y no se puede paralelizar —ningún truco adelanta un dato que aún no existe—. Lo que sí puedes es minimizar la cadena: prefetchar la primera petición en el preload para que su leg arranque durante la navegación, mantener la profundidad de la cadena tan corta como el dominio permita, y no colgar de ella peticiones que en realidad no dependían.

// Dependencia REAL: el cliente sale del pedido, no hay como paralelizar
const pedido = await getPedido(pedidoId);
const cliente = await getCliente(pedido.clienteId); // necesita pedido

La disciplina, entonces, no es “nunca encadenes” sino “encadena solo lo que el dato obliga, y que cada eslabón se gane su lugar”. Un waterfall es aceptable exactamente en la medida en que refleja una dependencia genuina; deja de serlo en cuanto encadena cosas que podían haber volado juntas.

🪜

Escalera en red

Barras escalonadas de peticiones que no dependian entre si delatan un waterfall accidental que hay que aplanar.

🚀

Preload sin await

Invocar las queries en el preload sin esperarlas las dispara en paralelo antes de renderizar la ruta.

⛓️

Cadena minima

Cuando la dependencia es real, acorta la cadena y prefetcha su primer eslabon; no cuelgues de ella datos independientes.

⚠️
El await cómodo es la fábrica de waterfalls

La mayoría de los waterfalls no se diseñan: se escriben sin querer, porque await en serie es la forma más natural de teclear “necesito A y B”. Cada vez que pongas dos await seguidos, hazte una pregunta de una línea: ¿el segundo usa el resultado del primero? Si la respuesta es no, acabas de crear un peldaño gratis, y la corrección es mecánica —un Promise.all, o subir ambas al preload—. Este reflejo, aplicado siempre, elimina de raíz la clase entera de bugs de latencia por encadenamiento, porque los caza en el momento de escribirlos en vez de en un perfilado tardío bajo presión.

Un waterfall es latencia acoplada: paralelo es el estado natural, serie es la excepción que se justifica

La lección que corona el nivel es un cambio de default mental. Casi todos llegamos a la asincronía con el instinto de la secuencia: pido esto, espero, pido lo siguiente, espero —porque así razonamos los pasos, uno detrás de otro—. Pero ese instinto, trasladado a la red, acopla latencias que no tenían por qué tocarse: convierte tiempos que el mundo permitía vivir a la vez en tiempos que el usuario sufre sumados. Un waterfall no es un fallo de una petición concreta; es un fallo de relación entre peticiones, un acoplamiento temporal que tú introdujiste al ordenarlas en serie sin que el dato lo exigiera. Por eso la cura no es optimizar cada petición por separado —hacerlas más rápidas no las desencadena— sino reordenar su topología: preguntar, para cada par, si de verdad una necesita a la otra, y disparar en paralelo todo lo que no esté genuinamente encadenado. Invierte el default: que el paralelismo sea lo que asumes y la secuencia lo que justificas, y no al revés. El preload de la ruta es la encarnación arquitectónica de esa inversión —un lugar, arriba del todo, donde declaras de golpe todo lo que la pantalla necesita, sin await, para que salga junto— y query es lo que hace ese lugar barato, porque garantiza que declarar arriba no duplique al leer abajo. Cuando interiorizas que la latencia percibida no la fija la petición más lenta sino la cadena más larga que construiste, empiezas a ver los await en serie como lo que casi siempre son: acoplamientos accidentales esperando a ser paralelizados. Y ahí se cierra el nivel entero: dar identidad con clave a cada dato fue lo que permitió deduplicar, prefetchar, mutar con optimismo y revalidar con precisión; disparar en paralelo desde la ruta es cosechar todo eso en la única métrica que el usuario siente de verdad, el tiempo que espera mirando la pantalla.

⚔️ Aplana la escalera
  1. Escribe una función con dos await en serie sobre datos que solo dependen de un mismo id y observa la escalera en la pestaña de red.
  2. Reescríbela con Promise.all y confirma que las barras pasan a arrancar alineadas y solaparse.
  3. Sube las tres queries de una ruta a su preload sin await y comprueba que se disparan en paralelo antes del render, con query deduplicando las lecturas de los componentes.
  4. Construye una dependencia real —pedido y luego su cliente— y argumenta por qué no se puede paralelizar y cómo minimizarías la cadena.
  5. Ante un tercer dato que colgaste de esa cadena por comodidad, detecta que no dependía y desengánchalo al preload para que vuele en paralelo.