El impuesto de la hidratación: cuando la página se ve pero no responde
El valle entre el pintado y la interactividad, cómo medirlo cuando ya no existe una métrica estándar, y los dos costes que paga toda página hidratada.
Renderizar en el servidor resuelve el problema de que la página tarde en verse y crea uno nuevo: la página se ve completa, con sus botones y su formulario, y durante uno o dos segundos no hace nada. El usuario pulsa, no pasa nada, vuelve a pulsar. Ese intervalo no aparece en el LCP, que ya se dio por bueno, ni en el INP, que solo mide interacciones que llegaron a procesarse. Es un coste real que hay que medir aparte porque ninguna métrica estándar lo captura.
- Describir el valle entre el pintado del contenido y la capacidad de responder.
- Instrumentar la ventana y los clics perdidos, ya que no hay métrica estándar que lo haga.
- Cuantificar los dos costes de hidratar: la ejecución y los datos duplicados.
- Explicar por qué el coste escala con el árbol completo y no con la parte interactiva.
El valle entre ver y poder
La secuencia de una página renderizada en servidor con cliente hidratado es esta:
- Llega el HTML con el contenido. El navegador lo pinta. La página se ve.
- Se descarga el paquete de JavaScript.
- Se analiza y se compila.
- El framework recorre el árbol de componentes, lo vuelve a construir en memoria, lo empareja con los nodos que ya existen y registra los manejadores de eventos.
- La página responde.
Entre el punto 1 y el punto 5 hay un intervalo durante el cual todo parece funcionar y nada funciona. Y es peor que una pantalla en blanco, porque una pantalla en blanco comunica correctamente que hay que esperar; una página completa comunica que se puede usar. El usuario pulsa el botón, no pasa nada, y su conclusión no es “está cargando” sino “esto va mal”.
El paso 4 es la hidratación, y su coste tiene una propiedad incómoda: es proporcional al tamaño del árbol renderizado, no a la parte que es interactiva. Si tu página tiene tres mil componentes y solo el buscador y el menú responden a clics, el framework recorre igualmente los tres mil, porque no sabe cuáles necesitan manejadores hasta que los ha ejecutado. Estructuralmente, hidratar cuesta al menos lo mismo que habría costado renderizar ese árbol en el cliente, más el trabajo de emparejarlo con el DOM existente.
Esa observación es la que motiva todo el nivel siguiente. Si el coste escalase con la interactividad, no habría problema: la mayoría de las páginas son casi todo contenido. Como escala con el tamaño, cuanto mejor haces tu SSR —cuanto más contenido metes en el HTML, que es exactamente lo que mejora el LCP— más caro es hidratar. El beneficio y el coste crecen juntos.
Medir la ventana
No hay métrica estándar. La que existía, el tiempo hasta interactivo, se retiró de la puntuación de Lighthouse por ser poco fiable y correlacionar mal con lo que el usuario percibe. El bloqueo total sigue siendo un buen indicador de laboratorio, y el INP mide la respuesta a interacciones reales, pero ninguno mide el hueco.
Hay que instrumentarlo. Dos medidas complementarias.
La ventana. El tiempo entre el LCP y el momento en que el framework declara la hidratación terminada.
let lcp = 0;
new PerformanceObserver((lista) => {
for (const entrada of lista.getEntries()) lcp = entrada.startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
// Llamar desde el callback de fin de hidratacion de tu framework.
export function marcarHidratado() {
const ahora = performance.now();
performance.measure('ventana-muerta', { start: lcp, end: ahora });
navigator.sendBeacon('/rum', JSON.stringify({
tipo: 'hidratacion',
lcp: Math.round(lcp),
hidratado: Math.round(ahora),
ventana: Math.round(ahora - lcp),
ruta: location.pathname,
}));
}
Los clics perdidos. Más brutal y mucho más convincente en una reunión: cuántos usuarios pulsaron algo durante la ventana y no obtuvieron respuesta.
const inicio = performance.now();
let interactiva = false;
addEventListener(
'click',
(evento) => {
if (interactiva) return;
navigator.sendBeacon('/rum', JSON.stringify({
tipo: 'clic-perdido',
msDesdeInicio: Math.round(performance.now() - inicio),
objetivo: evento.target.closest('[data-id]')?.dataset.id
?? evento.target.tagName,
}));
},
{ capture: true } // en captura: llega antes que cualquier manejador
);
export function marcarInteractiva() {
interactiva = true;
}
La fase de captura es esencial: así el listener se ejecuta antes que cualquier manejador de la aplicación y registra también los clics que sí llegaron a atenderse pero con retraso.
El dato que sale de aquí es el que cambia conversaciones. “El LCP es de 1,8 segundos” no mueve a nadie. “El nueve por ciento de las sesiones pulsan un botón que no responde, y en móvil de gama baja es el veintitrés por ciento” mueve presupuestos.
Para el desglose de dónde se va el tiempo dentro de la ventana, la API de fotogramas largos da la atribución por script, incluida la duración de bloqueo:
new PerformanceObserver((lista) => {
for (const f of lista.getEntries()) {
if (f.startTime > 8000) continue; // solo la fase de carga
for (const s of f.scripts) {
if (s.duration < 30) continue;
console.log(Math.round(s.duration), 'ms', s.sourceURL,
s.sourceFunctionName, s.invokerType);
}
}
}).observe({ type: 'long-animation-frame', buffered: true });
Los dos costes
El coste de ejecución es el que se acaba de describir: recorrer el árbol, ejecutar cada componente, emparejar con el DOM y registrar manejadores. Se mide en el perfil como una o varias tareas largas justo después de cargar el paquete. En un móvil de gama media, un árbol de unos pocos miles de componentes se mide en cientos de milisegundos con facilidad.
El coste de datos duplicados es el que más se pasa por alto y a menudo el más caro en bytes. Para que el cliente pueda reconstruir el mismo árbol que construyó el servidor, necesita los mismos datos. Así que la respuesta lleva el contenido dos veces: una como HTML renderizado y otra como estado serializado en un script en línea. Una lista de cien productos viaja como cien bloques de marcado y además como cien objetos JSON.
Mídelo en tu propia página, cuesta diez segundos:
const bytes = Array.from(
document.querySelectorAll('script:not([src])')
).reduce((suma, s) => suma + new Blob([s.textContent]).size, 0);
console.log(
'estado en linea:', (bytes / 1024).toFixed(1), 'KB',
'| documento:', (new Blob([document.documentElement.outerHTML]).size / 1024).toFixed(1), 'KB'
);
En muchas aplicaciones, ese estado en línea es entre el treinta y el sesenta por ciento del peso del documento. Comprime bien, porque es JSON repetitivo, pero comprimido sigue pesando y además hay que analizarlo con JSON.parse, que en cargas grandes es tiempo de hilo principal medible.
Qué reduce el impuesto y qué no
No lo reduce dividir el paquete. Dividir reduce lo que hay que descargar, y eso ayuda a los pasos 2 y 3, pero el paso 4 sigue recorriendo el árbol entero cuando el código llega. Es una mejora real y modesta.
No lo reduce renderizar menos en el servidor. Sí reduce la hidratación, pero empeora el LCP a cambio. Es mover el problema.
Sí lo reduce hidratar solo una parte del árbol. Que es la hidratación parcial y, llevada al extremo, la arquitectura de islas.
Sí lo reduce hidratar más tarde o por trozos. Que es la hidratación progresiva.
Sí lo elimina no hidratar en absoluto. Que es la resumibilidad, un enfoque distinto que serializa el estado de forma que el cliente pueda continuar sin reconstruir nada.
Las cuatro son el contenido del nivel de hidratación, donde se explican por dentro. Lo que importa fijar aquí es el criterio de evaluación: cualquier propuesta que oigas sobre hidratación se juzga con dos preguntas. ¿Reduce el trabajo o solo lo mueve en el tiempo? Y ¿reduce también el estado duplicado, o solo la ejecución? Muchas soluciones populares responden bien a la primera y mal a la segunda.
Merece la pena ver el problema en su forma abstracta, porque así se entiende por qué las optimizaciones incrementales dan tan poco. Una aplicación con SSR y cliente hidratado describe la interfaz dos veces: una en el servidor, que produce marcado, y otra en el navegador, que produce una estructura de datos equivalente para poder seguir gestionándola. Las dos descripciones tienen que coincidir exactamente, hasta el punto de que si difieren el framework lo considera un error y a veces descarta y rehace el marcado del servidor. Es decir: se ha hecho el mismo trabajo dos veces, en dos máquinas de velocidades muy distintas, con la obligación de que ambos resultados sean idénticos, y transportando entre ellas los datos de entrada para que puedan serlo. Cuando lo planteas así se ve que no hay ninguna optimización pequeña que arregle eso, porque la duplicación no es un accidente de implementación: es el diseño. Y también se ve que las cuatro soluciones que existen son las cuatro formas posibles de atacar una duplicación. Puedes hacer menos la segunda vez —hidratación parcial, islas: solo se describe dos veces lo que necesita ser interactivo—. Puedes repartirla en el tiempo —hidratación progresiva: se sigue haciendo todo, pero cuando no molesta—. Puedes no hacerla —resumibilidad: el servidor serializa lo suficiente para que el cliente continúe sin reconstruir—. O puedes no hacer la primera —cliente puro: renuncias al SSR y a su LCP—. No hay una quinta. Cualquier propuesta nueva que veas encaja en una de esas cuatro casillas, y colocarla en su casilla te dice inmediatamente qué gana y qué pierde, sin necesidad de creerte el material de marketing. Esta es, por cierto, una forma de pensar que sirve mucho más allá del frontend: cuando un sistema es caro por hacer lo mismo dos veces, la lista de salidas siempre es la misma y siempre es corta.
- Instrumenta la ventana entre LCP e hidratación y publica su percentil 75 segmentado por tipo de dispositivo.
- Registra los clics perdidos durante una semana. Calcula el porcentaje de sesiones afectadas.
- Mide el peso del estado serializado en línea de tus tres rutas principales y su proporción sobre el documento.
- Usa la API de fotogramas largos para atribuir los cien primeros milisegundos de bloqueo tras la carga a funciones concretas.
- Estima cuántos de tus componentes son realmente interactivos. Compara con el total del árbol.