wandres.dev
HIDRATACIÓN · El impuesto del SSR

Resumibilidad: continuar en vez de reconstruir

Cómo se serializa un cierre para que el cliente lo ejecute sin haber ejecutado nunca el árbol, por qué el arranque pasa a ser constante, y qué exige a cambio.

⏱ 19 min

Las tres técnicas anteriores aceptan que el cliente tiene que reconstruir el árbol de componentes y discuten cuánto y cuándo. La resumibilidad rechaza la premisa: si el problema es que el cliente necesita el estado y los cierres que el servidor ya tenía, la solución es mandárselos, serializados, y que continúe desde ahí sin ejecutar nada. El cambio no es de grado sino de categoría, porque el coste de arranque deja de depender del tamaño de la aplicación.

🎯 Al terminar esta lección sabrás
  • Explicar en qué se diferencia reanudar de hidratar en términos de trabajo realizado.
  • Describir cómo se serializa un manejador con las variables que captura.
  • Situar dónde se traslada el coste: tamaño del HTML y petición en la primera interacción.
  • Enumerar las restricciones reales que impone y cuándo compensan.

La idea: continuar en vez de reconstruir

Cuando el servidor renderiza, tiene todo lo necesario: el árbol de componentes, el estado, y los cierres de cada manejador con sus variables capturadas. Al terminar, tira todo eso y manda solo el HTML. El cliente lo reconstruye desde cero. Esa es la hidratación.

La pregunta de la resumibilidad es: ¿y si no lo tiramos? Si el servidor serializa el estado y anota, junto a cada elemento interactivo, qué función hay que ejecutar y con qué variables capturadas, el cliente no necesita ejecutar nada por adelantado. Necesita un fragmento minúsculo de código que escuche eventos globalmente y que, cuando llegue el primero, lea la anotación, descargue esa función concreta y la invoque con su estado.

El resultado es que la carga de una página no ejecuta código de aplicación. Ninguno. El arranque cuesta lo que cuesta descargar y ejecutar un kilobyte de escucha, sea la aplicación de cinco componentes o de cinco mil.

Qwik es la implementación de referencia de esta idea y es donde conviene estudiarla, pero el concepto es independiente del framework.

Cómo se serializa un cierre

Aquí está la parte técnicamente interesante, porque un cierre no es un dato: es código más un entorno.

El código se resuelve en tiempo de compilación. Un paso del compilador extrae cada función marcada como frontera a su propio módulo, con un nombre estable. Donde tú escribiste una función en línea, el resultado compilado es un módulo aparte y una referencia con la forma “en este fichero, este símbolo”.

El entorno se resuelve por serialización. Todas las variables que el cierre captura se recogen en una tabla global de estado que se emite al final del HTML, y la referencia del manejador incluye los índices de sus capturas dentro de esa tabla.

Esquemáticamente, el HTML resultante tiene esta forma:

<button on:click="/build/q-a3f0.js#s_boton_comprar[0,2]">
  Comprar
</button>

<!-- ... al final del documento ... -->
<script type="application/json" id="estado">
  [ {"id":42,"precio":19.9}, "eur", {"unidades":1} ]
</script>

El atributo dice tres cosas: de qué fichero cargar, qué símbolo invocar, y qué posiciones de la tabla de estado son las variables que ese cierre capturaba. Cuando el usuario pulsa, el escuchador global —registrado en el documento desde el primer momento— lee el atributo, importa el módulo, reconstruye el cierre con los valores de las posiciones 0 y 2, y lo llama.

Tres detalles que hacen que esto funcione de verdad y que no son obvios.

El escuchador es global y minúsculo. Un único manejador delegado en el documento para todos los tipos de evento que aparecen en la página. No recorre nada, no conoce el árbol, no tiene estado propio.

La deserialización es perezosa. La tabla de estado puede ser grande, y analizarla entera al cargar reintroduciría el problema que se quería evitar. Lo que se hace es envolverla en proxies que deserializan cada rama la primera vez que alguien la lee. Si el usuario solo pulsa un botón, solo se deserializa lo que ese botón necesita.

El grafo de estado admite referencias compartidas y ciclos. Dos componentes que apuntan al mismo objeto tienen que seguir apuntando al mismo objeto después de deserializar, o el estado deja de estar sincronizado. Por eso el formato no es JSON plano sino una tabla con referencias por índice: es lo que permite reconstruir un grafo y no un árbol.

El coste se mueve a otro sitio

Ninguna técnica elimina trabajo; lo reubica. Aquí se reubica en dos sitios muy concretos.

En el tamaño del HTML. El estado serializado viaja en el documento. En páginas con mucho estado —una tabla con doscientas filas y su modelo completo— eso pesa. La contrapartida es que ese peso sustituye al que ya pagabas con hidratación, donde el estado también viajaba duplicado; la diferencia es que aquí no hay además un paquete de framework que ejecutar.

En la primera interacción. Cuando el usuario pulsa por primera vez, hay que ir a buscar el módulo de ese manejador. Si no está en caché, es una petición de red en el camino crítico de la interacción, y eso cuenta para el INP.

La mitigación es una precarga especulativa: el framework emite, junto al HTML, la lista de módulos que probablemente se necesiten, y un service worker o unos link de precarga los traen durante el tiempo ocioso. Bien afinado, cuando el usuario pulsa, el módulo ya está en caché y la interacción cuesta la ejecución sola. Mal afinado —una conexión lenta, una lista de precarga demasiado optimista— la primera pulsación de cada componente tiene una ida y vuelta encima.

Este es exactamente el mismo intercambio del disparador de interacción de la hidratación progresiva, con dos diferencias a favor: aquí los trozos son mucho más pequeños, porque la unidad es una función y no un componente entero, y la precarga la calcula el framework en vez de tú.

Qué exige y qué limita

Ser honesto con los requisitos importa más que la promesa, porque son los requisitos los que deciden si esto encaja en un proyecto.

Exige un compilador y una sintaxis de frontera. El programador tiene que marcar dónde están las fronteras de serialización. En Qwik eso se hace con un sufijo en las funciones. No es opcional ni deducible: el compilador necesita saber qué extraer.

Restringe lo que se puede capturar. Todo lo que un cierre capture tiene que ser serializable. Objetos planos, arrays, primitivos, fechas, mapas, conjuntos y referencias a otros objetos serializados, sí. Instancias de clases con métodos, funciones arbitrarias, conexiones abiertas, no. Cuando lo necesitas, hay que reestructurar para que lo que cruce sea un dato y no un comportamiento.

Rompe la compatibilidad con el ecosistema. Una librería de componentes escrita para un modelo de hidratación asume que se ejecuta al cargar. Hay capas de compatibilidad, con su coste, pero no es cierto que puedas usar cualquier paquete existente sin pensar.

Cambia el modelo mental del equipo. El razonamiento sobre qué se ejecuta y cuándo deja de ser el habitual. Es aprendible y no es complicado, pero es distinto, y ese coste hay que contarlo.

La comparación honesta, entonces:

Hidratación completa Islas Resumibilidad
Coste de arranque Proporcional al árbol Proporcional a las islas Constante
Primera interacción Rápida si ya hidratado Rápida si ya hidratada Puede exigir red
Peso del HTML Contenido más estado Contenido más props Contenido más grafo de estado
Ecosistema Completo Completo por isla Restringido
Curva de aprendizaje Ninguna Baja Media
El argumento de la resumibilidad no es sobre el valor de la métrica: es sobre su derivada

Cuando se comparan enfoques con una demostración, la resumibilidad gana por poco o incluso pierde, y eso ha llevado a mucha gente a descartarla. Una página de ejemplo tiene diez componentes; hidratarlos cuesta veinte milisegundos, que es indistinguible de cero, y el coste añadido de la serialización y de la petición en la primera interacción puede incluso salir perdiendo. Con esa comparación, la conclusión es que no compensa. El problema es que la comparación mide el valor y lo interesante es la pendiente. En una aplicación con hidratación, el coste de arranque crece con cada componente que añades. No de golpe, no de forma perceptible en ningún despliegue concreto: dos milisegundos por aquí, cinco por allá. Al cabo de tres años de desarrollo con seis personas, tienes ochocientos milisegundos de arranque que nadie introdujo y que nadie sabe cómo quitar, porque no hay un culpable: hay cuatro mil incrementos legítimos. Es el patrón más frecuente de degradación de rendimiento en aplicaciones grandes y es prácticamente irreversible, porque revertirlo exigiría deshacer tres años de funcionalidad. En una aplicación resumible ese crecimiento no existe: el arranque es constante por construcción, y añadir el componente número cuatro mil no lo empeora. Eso convierte el debate en algo distinto de una comparación de números. La pregunta correcta no es “¿cuál es más rápido hoy?” sino “¿cuál sigue siendo aceptable dentro de tres años sin que nadie tenga que dedicarse a impedir que se degrade?”. Y esta forma de razonar se aplica mucho más allá de este caso concreto, porque hay una familia entera de decisiones arquitectónicas cuyo valor real está en la derivada y no en el valor: los tipos estáticos, los tests, los límites de módulo, los presupuestos automáticos en integración continua. Todas parecen no compensar cuando el proyecto es pequeño, y todas son la diferencia entre un proyecto que envejece bien y uno que no. Con la resumibilidad, además, hay que ser honesto con el otro lado de la balanza: hoy paga un precio real en ecosistema y en familiaridad del equipo, y ese precio también se contabiliza. La decisión razonable depende de cuántos años esperas mantener la aplicación y de cuánto va a crecer, no de un número de una demostración.

⚔️ Evalúa la idea con tus datos
  1. Mide el coste de arranque de tu aplicación hoy y estima cómo ha crecido comparando con una versión de hace un año, si la tienes desplegada.
  2. Cuenta cuántos de tus manejadores capturan algo que no sería serializable. Es la medida del esfuerzo de migración.
  3. Estima el peso del grafo de estado de tu ruta más pesada: cuenta los datos que los componentes interactivos necesitan.
  4. Comprueba en qué proporción tus dependencias de interfaz asumen ejecución al cargar.
  5. Escribe la comparación de las tres opciones para tu producto concreto, con la pendiente y no solo con el valor.