Hidratación progresiva: los cinco disparadores y su precio
Hidratar al cargar, en tiempo libre, al ser visible, por consulta de medios o al interactuar, con una implementación propia y la métrica que puede empeorar.
Si el trabajo de hidratación no se puede eliminar, al menos se puede repartir. Ese es el objetivo de la hidratación progresiva: en vez de una tarea larga justo después de cargar el paquete, muchas tareas pequeñas repartidas por el tiempo, cada una disparada cuando su componente empieza a importar. La idea es tan buena que se ha convertido en el valor por defecto de varios frameworks, y también tan fácil de aplicar mal que hay una configuración concreta que mejora todas las métricas de laboratorio y empeora la experiencia real.
- Elegir el disparador adecuado para cada isla entre los cinco disponibles.
- Implementar un elemento personalizado que hidrata bajo demanda sin framework.
- Aplicar el patrón de hidratar al interactuar con precarga anticipada.
- Distinguir qué métricas mejora cada disparador y cuál puede degradar el INP.
Los cinco disparadores
Al cargar. Se hidrata en cuanto el módulo está disponible. Es el comportamiento por defecto y el correcto para lo que el usuario va a usar inmediatamente: el buscador de la cabecera, el selector de idioma, un formulario de acceso.
En tiempo libre. Se hidrata cuando el navegador no tiene nada mejor que hacer, mediante requestIdleCallback. Es la opción por defecto sensata para casi todo lo que no está en el primer viewport: no compite con el renderizado inicial y llega mucho antes de que el usuario lo necesite. Conviene ponerle un plazo máximo para que no se posponga indefinidamente en dispositivos que nunca están ociosos.
Al ser visible. Se hidrata cuando el componente entra en el viewport, con IntersectionObserver y un margen generoso. Es la opción para lo que está por debajo del pliegue y es pesado: un carrusel, un mapa, un gráfico.
Por consulta de medios. Se hidrata solo si se cumple una condición de medios. El caso real es el menú lateral que solo es interactivo en móvil porque en escritorio está siempre desplegado: en escritorio, cero JavaScript.
Al interactuar. No se hidrata hasta que el usuario toca el componente. Es el más agresivo y el que tiene el precio que se explica más abajo.
Hay un sexto caso que no es un disparador sino una declaración: solo en cliente, para componentes que no se pueden renderizar en el servidor porque dependen de APIs del navegador. No es una optimización, es una necesidad, y tiene el coste de que ese hueco está vacío hasta que llega el JavaScript.
Un elemento personalizado que lo implementa
Los frameworks traen esto de serie, pero implementarlo tú mismo aclara el mecanismo y sirve para cualquier base de código, incluida una sin framework.
// isla-perezosa.js
class IslaPerezosa extends HTMLElement {
#hidratada = false;
#control = new AbortController();
connectedCallback() {
const cuando = this.getAttribute('cuando') ?? 'idle';
if (cuando === 'load') {
this.#hidratar();
return;
}
if (cuando === 'idle') {
const enReposo =
window.requestIdleCallback ?? ((fn) => setTimeout(fn, 200));
// El plazo evita que se posponga para siempre en un movil
// que nunca esta ocioso.
enReposo(() => this.#hidratar(), { timeout: 3000 });
return;
}
if (cuando === 'visible') {
const observador = new IntersectionObserver(
(entradas) => {
if (!entradas.some((e) => e.isIntersecting)) return;
observador.disconnect();
this.#hidratar();
},
{ rootMargin: '250px' }
);
observador.observe(this);
this.#control.signal.addEventListener('abort', () =>
observador.disconnect()
);
return;
}
if (cuando === 'media') {
const consulta = matchMedia(this.getAttribute('media') ?? 'all');
const comprobar = () => consulta.matches && this.#hidratar();
comprobar();
consulta.addEventListener('change', comprobar, {
signal: this.#control.signal,
});
return;
}
if (cuando === 'interaccion') {
const { signal } = this.#control;
const alInteractuar = async (evento) => {
this.#control.abort(); // quita los cuatro de golpe
await this.#hidratar();
this.#reproducir(evento);
};
for (const tipo of ['pointerdown', 'focusin', 'keydown']) {
this.addEventListener(tipo, alInteractuar, { signal });
}
// La precarga del modulo al pasar el raton: cuando llegue el
// clic, el codigo ya esta descargado.
this.addEventListener('pointerenter', () => this.#precargar(), {
signal,
once: true,
});
}
}
disconnectedCallback() {
this.#control.abort();
}
#precargar() {
const url = this.getAttribute('modulo');
if (url) import(/* @vite-ignore */ url);
}
async #hidratar() {
if (this.#hidratada) return;
this.#hidratada = true;
performance.mark('isla:' + this.id + ':inicio');
const url = this.getAttribute('modulo');
const { montar } = await import(/* @vite-ignore */ url);
const props = JSON.parse(this.getAttribute('props') ?? '{}');
montar(this, props);
performance.mark('isla:' + this.id + ':fin');
performance.measure(
'isla:' + this.id,
'isla:' + this.id + ':inicio',
'isla:' + this.id + ':fin'
);
}
#reproducir(evento) {
// Reproduccion aproximada: un pointerdown que iba a ser un clic
// se reemite como clic sobre el mismo objetivo.
if (evento.type !== 'pointerdown') return;
evento.target.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
}
}
customElements.define('isla-perezosa', IslaPerezosa);
En el HTML del servidor, con el contenido ya renderizado dentro para que se vea antes de hidratar:
<isla-perezosa
id="carrusel-1"
cuando="visible"
modulo="/islas/carrusel.js"
props='{"total":12}'>
<!-- HTML renderizado en el servidor: visible desde el primer momento -->
<div class="carrusel"><img src="/1.avif" alt="" width="800" height="450"></div>
</isla-perezosa>
Las marcas de rendimiento no son decorativas: sin ellas no sabrás cuánto cuesta cada isla ni cuándo se hidrata cada una, y esa es la información que necesitas para ajustar los disparadores.
Hidratar al interactuar y su precio
El disparador de interacción es el que más JavaScript ahorra y el único que tiene un coste directo sobre la experiencia. La secuencia cuando el usuario pulsa es:
- El evento llega al elemento sin hidratar.
- Se descarga el módulo, si no estaba ya.
- Se ejecuta y se monta.
- Se reproduce el evento.
Los pasos 2 y 3 ocurren entre la pulsación y la respuesta, así que cuentan íntegros para el INP de esa interacción. Con el módulo ya en caché son unas decenas de milisegundos y no hay problema. Con el módulo por descargar en una red móvil, son cientos, y ese primer clic es horrible.
La mitigación es la precarga anticipada, que es lo que hace el pointerenter del código de arriba. Entre que el puntero entra en un elemento y que el usuario hace clic pasan típicamente entre cien y trescientos milisegundos, tiempo más que suficiente para descargar un módulo pequeño. En táctil no hay pointerenter, pero pointerdown ocurre bastante antes que click —el usuario tiene que levantar el dedo— y da unos cuantos milisegundos gratis.
Con esa mitigación, el disparador de interacción es razonable para elementos con una zona de entrada clara: un menú, un desplegable, un editor que se abre al pulsar. Sin ella, es una forma elegante de convertir un problema de carga en un problema de INP.
Qué mejora cada disparador y qué no
| Disparador | Bloqueo total | LCP | INP de la primera interacción | JavaScript enviado |
|---|---|---|---|---|
| Al cargar | Igual | Puede empeorar | Bueno | Igual |
| En tiempo libre | Baja mucho | Mejora | Bueno | Igual |
| Al ser visible | Baja mucho | Mejora | Bueno si ya es visible | Menos, si nunca se ve |
| Por medios | Baja | Mejora | Bueno | Menos en la otra condición |
| Al interactuar | Baja al máximo | Mejora | Puede empeorar mucho | Mucho menos |
La fila que hay que leer dos veces es la última. El disparador de interacción es el que mejor puntúa en cualquier auditoría de laboratorio, porque las auditorías miden la carga y no interactúan. Es perfectamente posible subir veinte puntos de rendimiento en una herramienta automática y que el usuario perciba la web peor, porque cada primera pulsación sobre cada componente tiene ahora medio segundo de retraso.
La hidratación progresiva se vende como una reducción de coste y no lo es: el trabajo total es idéntico, solo cambia cuándo se hace. Eso significa que su valor depende por completo de si el momento al que lo mueves es un momento barato, y solo hay una ventana genuinamente barata en la vida de una página: el tiempo en que el navegador está ocioso y el usuario todavía no ha interactuado. En esa ventana, el trabajo es literalmente gratis desde el punto de vista de la percepción: nadie está esperando nada, no hay ningún fotograma que se retrase, no hay ninguna métrica que se degrade. Todo lo que consigas meter ahí lo has ganado. Fuera de esa ventana, mover trabajo es un intercambio con signo, no una ganancia. Si lo mueves al momento en que el usuario está interactuando, lo has empeorado, porque has cambiado tiempo que nadie percibía por tiempo que se percibe con una sensibilidad de cien milisegundos. Y si lo mueves al momento en que el usuario está haciendo scroll, has cambiado tiempo invisible por tirones en el desplazamiento, que es de lo más molesto que existe. De aquí sale la política que funciona y que es más conservadora de lo que la gente espera: por defecto, tiempo libre; para lo pesado que está fuera de pantalla, visibilidad con margen generoso; para lo que solo aplica en una condición, medios; y el disparador de interacción solo para componentes grandes con precarga anticipada garantizada. Hay un corolario incómodo sobre las herramientas. Las auditorías automáticas miden exclusivamente la fase de carga, así que puntúan cualquier aplazamiento como una mejora, sin distinguir si lo aplazaste a la ventana gratis o encima de la interacción del usuario. Es decir, la métrica con la que casi todo el mundo gobierna es sistemáticamente ciega al único riesgo real de esta técnica. Si vas a usar hidratación bajo demanda de forma agresiva, la condición no negociable es que estés midiendo el INP en usuarios reales; si solo tienes un número de laboratorio, estás optimizando a ciegas en la dirección exacta en la que ese número miente.
- Inventaría tus islas y asigna a cada una el disparador que le corresponde, justificándolo.
- Implementa el elemento personalizado y añade las marcas de rendimiento. Recoge cuánto tarda cada isla en hidratarse.
- Convierte una isla pesada a disparador de interacción sin precarga y mide su INP. Añade la precarga y vuelve a medir.
- Comprueba en un móvil real qué pasa con el disparador de tiempo libre: mide cuánto tarda en dispararse y si el plazo máximo llega a activarse.
- Compara la puntuación de una auditoría automática antes y después de mover todo a interacción, y contrástala con el INP de campo.