Por qué hidratar es caro: el mecanismo por dentro
Qué hace exactamente el framework al hidratar, por qué no basta con enganchar manejadores, el contrato de coincidencia entre servidor y cliente, y cómo medirlo.
La explicación popular de la hidratación —“el JavaScript engancha los manejadores de eventos al HTML que mandó el servidor”— es tan corta que induce a error. Si fuera eso, hidratar costaría microsegundos: recorrer unos cuantos nodos y llamar a addEventListener. Lo que ocurre en realidad es que el cliente reconstruye el árbol de componentes entero, ejecutando cada función de componente, para poder demostrar que produciría el mismo resultado que produjo el servidor. Ese es el coste, y entenderlo es lo que permite juzgar las alternativas.
- Describir paso a paso lo que hace un framework al hidratar un árbol.
- Explicar por qué los manejadores no se pueden enganchar sin ejecutar los componentes.
- Enumerar las restricciones que impone el contrato de coincidencia entre servidor y cliente.
- Medir el coste de hidratar y compararlo con el de renderizar desde cero.
Qué hace exactamente el framework
Al hidratar, el framework recorre el árbol de componentes desde la raíz y, para cada uno:
- Ejecuta la función del componente con sus props, produciendo su descripción de la interfaz.
- Recorre esa descripción en paralelo con el DOM existente, emparejando cada elemento descrito con el nodo que el servidor ya escribió.
- Comprueba que coinciden: mismo tipo de etiqueta, mismo orden, mismo texto.
- Guarda la referencia del nodo real en su estructura interna, para poder actualizarlo después.
- Registra los manejadores que ese componente declaró, ya sea en el nodo o —lo habitual— en un delegador en la raíz.
- Inicializa el estado: hooks, señales, suscripciones, efectos.
El paso 1 es el caro y es el que sorprende. El cliente vuelve a ejecutar todo tu código de componentes. Toda la lógica de presentación, los map sobre los datos, los formateos de fecha, los cálculos derivados, la construcción de clases CSS condicionales: todo eso se ejecuta en el servidor para producir el HTML y se vuelve a ejecutar en el navegador, sobre los mismos datos, para producir un resultado que ya tienes delante.
El paso 2 tampoco es gratis. A diferencia del renderizado desde cero, donde el framework puede construir nodos de golpe y adjuntarlos en bloque, la hidratación tiene que caminar el DOM nodo a nodo, en el mismo orden, comprobando en cada paso. Es un recorrido con verificación, y en la práctica eso hace que hidratar un árbol cueste a menudo más que renderizarlo desde cero, que es lo contrario de lo que casi todo el mundo asume.
Por qué no basta con enganchar manejadores
La pregunta natural es: si el HTML ya está, ¿por qué no recorrerlo y poner los listeners donde toque?
Porque un manejador no es una función suelta: es un cierre sobre el estado y las props del componente. Considera esto:
function BotonBorrar({ id, onBorrado }) {
const [enviando, setEnviando] = useState(false);
return (
<button
disabled={enviando}
onClick={async () => {
setEnviando(true);
await fetch('/api/items/' + id, { method: 'DELETE' });
onBorrado(id);
}}
>
Borrar
</button>
);
}
El HTML que llega del servidor es <button>Borrar</button>. Para construir el manejador de clic hace falta conocer id, onBorrado y la función setEnviando, que solo existen tras ejecutar el componente con sus props, que a su vez vienen del componente padre, que a su vez las calculó con sus propias props. No hay forma de reconstruir el cierre sin ejecutar la cadena entera desde la raíz.
Esta es la razón profunda, y es estructural, no una limitación de implementación. Un framework que quiera evitarla tiene que resolver el problema de otra manera: o bien serializar el cierre en el HTML para poder reconstruirlo sin ejecutar el árbol —que es la resumibilidad—, o bien reducir el árbol que hay que ejecutar —que es la hidratación parcial y las islas—. Las dos son el contenido de las lecciones siguientes, y las dos salen directamente de este análisis.
El contrato de coincidencia y lo que prohíbe
Como el cliente vuelve a producir la descripción de la interfaz y la compara con lo que hay, servidor y cliente están obligados a coincidir. Ese contrato tiene consecuencias que aparecen como bugs desconcertantes.
Cualquier valor que difiera entre las dos ejecuciones rompe la coincidencia:
// Todos estos rompen la hidratacion.
<span>{new Date().toLocaleTimeString()}</span> // hora distinta
<div id={'x-' + Math.random()}>...</div> // aleatorio distinto
{typeof window !== 'undefined' && <Widget />} // rama distinta
<p>{navigator.language}</p> // no existe en servidor
<time>{fecha.toLocaleDateString()}</time> // zona horaria distinta
El último es el más traicionero de todos, porque no falla en desarrollo: tu servidor y tu navegador están en la misma zona horaria. Falla en producción, para los usuarios que están en otra, y produce un desajuste que el framework corrige silenciosamente o ruidosamente según la versión.
Qué ocurre ante un desajuste depende del framework, pero el patrón moderno es: se registra un error en consola y se descarta el HTML del servidor de ese subárbol, renderizándolo desde cero en el cliente. Y ahí está el coste oculto: un desajuste no es solo un aviso feo, es la pérdida de todo el beneficio del SSR en esa parte de la página, más el trabajo extra de destruir y reconstruir. Un desajuste en un componente alto del árbol puede convertir tu página renderizada en servidor en una página renderizada en cliente sin que nadie se entere, salvo por una línea en la consola que nadie mira.
Las salidas correctas para los casos legítimos:
// 1. Renderizar lo estable y ajustar despues del montaje.
function Hora({ iso }) {
const [local, setLocal] = useState(null);
useEffect(() => setLocal(new Date(iso).toLocaleTimeString()), [iso]);
// El servidor y el primer render del cliente coinciden: ambos usan iso.
return <time dateTime={iso}>{local ?? iso}</time>;
}
// 2. Identificadores estables generados por el framework, no aleatorios.
function Campo({ etiqueta }) {
const id = useId(); // igual en servidor y en cliente
return (
<>
<label htmlFor={id}>{etiqueta}</label>
<input id={id} />
</>
);
}
La regla general: todo lo que dependa del entorno del navegador va después del montaje, nunca durante el render. Y todo lo que necesite ser único va por el generador del framework, no por Math.random.
Medirlo
La comparación que hay que hacer, y que casi nadie hace, es hidratar frente a renderizar desde cero el mismo árbol.
import { hydrateRoot, createRoot } from 'react-dom/client';
import { useLayoutEffect } from 'react';
function MarcaFin({ etiqueta }) {
useLayoutEffect(() => {
performance.mark(etiqueta + ':fin');
performance.measure(etiqueta, etiqueta + ':inicio', etiqueta + ':fin');
const m = performance.getEntriesByName(etiqueta).at(-1);
console.log(etiqueta, m.duration.toFixed(1) + ' ms');
}, []);
return null;
}
// Version A: hidratar el HTML que mando el servidor.
performance.mark('hidratar:inicio');
hydrateRoot(
document.getElementById('raiz'),
<>
<App datos={datos} />
<MarcaFin etiqueta="hidratar" />
</>
);
// Version B: renderizar el mismo arbol desde cero en un contenedor vacio.
// Ejecutar en una carga aparte, no en la misma.
const vacio = document.createElement('div');
document.body.appendChild(vacio);
performance.mark('render:inicio');
createRoot(vacio).render(
<>
<App datos={datos} />
<MarcaFin etiqueta="render" />
</>
);
El useLayoutEffect es imprescindible para medir bien: la hidratación de las versiones modernas de React es concurrente y no termina cuando hydrateRoot devuelve. Cronometrar alrededor de la llamada mide la programación del trabajo, no el trabajo. Los efectos de layout se ejecutan cuando el árbol ya está confirmado, así que marcan el final real.
Ejecuta las dos versiones con throttling de CPU a 4x y a 6x, no en tu máquina a pleno rendimiento. El coste de la hidratación es dominado por la ejecución de JavaScript, y esa es exactamente la dimensión en la que el móvil de tus usuarios y tu portátil más se diferencian.
La forma más útil de pensar en la hidratación es como una prueba. El servidor produjo un HTML afirmando implícitamente “esto es lo que el componente X con los datos D produce”. El cliente, para poder tomar el control de ese HTML y seguir actualizándolo, necesita convencerse de que él también produciría lo mismo, y la única forma que tiene de convencerse es rehacer el cálculo y comparar. No está renderizando: está verificando. Y de ahí sale, sin más razonamiento, todo lo demás. Sale por qué el coste es proporcional al tamaño del árbol y no a su interactividad: la prueba abarca lo que abarca el árbol, sean botones o párrafos. Sale por qué el contrato de coincidencia es tan rígido: una prueba con una discrepancia no es una prueba con un fallito, es una prueba que no vale, y por eso el framework tira el subárbol entero. Sale por qué hay que transportar los datos otra vez: sin las entradas no se puede rehacer el cálculo. Y sale, sobre todo, cuál es el espacio completo de soluciones, porque para abaratar una demostración solo hay tres caminos y los tres están explorados. Puedes demostrar menos: hidratación parcial e islas, donde solo se verifica lo que va a cambiar. Puedes demostrar más tarde o a plazos: hidratación progresiva y perezosa, donde la verificación se reparte para no bloquear. O puedes no demostrar nada porque el servidor te dejó la conclusión escrita: resumibilidad, donde el estado y las referencias a los manejadores viajan serializados y el cliente los usa sin recalcularlos. Cuando alguien te presente un enfoque nuevo, colócalo en una de las tres casillas antes de escuchar las cifras: te dirá inmediatamente qué está sacrificando. Y si no encaja en ninguna, o está haciendo algo genuinamente nuevo, o está midiendo mal.
- Instrumenta la hidratación de tu ruta principal con el patrón del efecto de layout y anota el resultado a 1x, 4x y 6x de throttling de CPU.
- Renderiza el mismo árbol desde cero en un contenedor vacío y compara. Explica el signo de la diferencia.
- Introduce a propósito un desajuste con
new Date()y observa qué hace tu framework: mira la consola y comprueba si el subárbol se descarta. - Busca en tu código todos los accesos a
window,navigatoroDatedurante el render. Cuéntalos. - Cuenta cuántos componentes de tu árbol tienen manejadores de eventos y calcula qué porcentaje del total representan.