Renderización en la Web

¿Dónde deberíamos implementar la lógica y la renderización en nuestras aplicaciones? ¿Deberíamos usar el procesamiento del servidor? ¿Qué ocurre con la rehidratación? Busquemos algunas respuestas.

Como desarrolladores, a menudo debemos tomar decisiones que afectarán a toda la arquitectura de nuestras aplicaciones. Una de las decisiones principales que deben tomar los desarrolladores web es dónde implementar la lógica y el procesamiento en sus aplicaciones. Esto puede ser difícil, ya que hay muchas formas diferentes de crear un sitio web.

Nuestro conocimiento sobre este espacio se basa en nuestro trabajo en Chrome durante los últimos años para interactuar con sitios grandes. En términos generales, recomendamos a los desarrolladores que consideren la renderización del servidor o la renderización estática por sobre un enfoque de rehidratación completa.

Para comprender mejor las arquitecturas que elegimos cuando tomamos esta decisión, necesitamos comprender bien cada enfoque y utilizar la terminología coherente al hablar sobre ellas. Las diferencias entre estos enfoques ayudan a ilustrar las ventajas y desventajas de renderizar en la Web desde la perspectiva del rendimiento.

Terminología

Renderización

  • Renderización del servidor (SSR): renderiza una app universal o del cliente en HTML en el servidor.
  • Renderización del cliente (CSR): Renderiza una app en un navegador mediante JavaScript para modificar el DOM.
  • Rehidratación: "Inicia" vistas de JavaScript en el cliente de modo que vuelvan a usar el árbol y los datos del DOM del HTML renderizado por el servidor.
  • Renderización previa: Ejecución de una aplicación del cliente en el tiempo de compilación para capturar su estado inicial como HTML estático

Rendimiento

Renderización del servidor

La renderización del servidor genera el código HTML completo de una página en el servidor en respuesta a la navegación. Esto evita recorridos de ida y vuelta adicionales para la recuperación y el uso de plantillas de datos en el cliente, ya que se controlan antes de que el navegador reciba una respuesta.

Por lo general, la renderización del servidor produce un FCP rápido. La ejecución de la lógica y el procesamiento de la página en el servidor permite evitar el envío de mucho código JavaScript al cliente. Esto ayuda a reducir el TBT de una página, lo que también puede generar un INP más bajo, ya que el subproceso principal no se bloquea tan a menudo durante la carga de la página. Cuando se bloquea el subproceso principal con menos frecuencia, las interacciones de los usuarios tendrán más oportunidades de ejecutarse antes. Esto tiene sentido, ya que con la renderización del servidor, en realidad solo envías texto y vínculos al navegador del usuario. Este enfoque puede funcionar bien para un amplio espectro de condiciones de dispositivos y redes, y ofrece optimizaciones interesantes para el navegador, como la transmisión de análisis de documentos.

Diagrama que muestra la renderización del servidor y la ejecución de JS que afectan a FCP y TTI.

Con la renderización del servidor, es menos probable que los usuarios tengan que esperar a que se ejecute JavaScript vinculado a la CPU para poder usar tu sitio. Incluso cuando no se pueda evitar el JS de terceros, usar la renderización del servidor para reducir tus costos de JavaScript propios puede proporcionarte más presupuesto para el resto. Sin embargo, este enfoque tiene una compensación potencial: generar páginas en el servidor lleva tiempo, lo que puede generar un TTFB más alto.

Que la renderización del servidor sea suficiente para tu aplicación depende en gran medida del tipo de experiencia que estés compilando. Desde hace mucho tiempo, existe un debate sobre las aplicaciones correctas de la renderización del servidor y del procesamiento del cliente, pero es importante recordar que puedes optar por usar la renderización del servidor para algunas páginas y no para otras. Algunos sitios adoptaron con éxito técnicas de renderización híbrida. El servidor de Netflix procesa sus páginas de destino relativamente estáticas mientras carga previamente el JS para las páginas con mucha interacción, lo que les da a estas páginas más pesadas renderizadas por clientes una mayor oportunidad de cargarse rápidamente.

Muchos frameworks, bibliotecas y arquitecturas modernos permiten renderizar la misma aplicación tanto en el cliente como en el servidor. Estas técnicas se pueden usar para la renderización del servidor. Sin embargo, es importante tener en cuenta que las arquitecturas en las que la renderización ocurre tanto en el servidor como en el cliente son su propia clase de solución con características y compensaciones de rendimiento muy diferentes. Los usuarios de React pueden usar APIs de DOM del servidor o soluciones compiladas sobre ellas, como Next.js para la renderización del servidor. Los usuarios de Vue pueden consultar la guía de renderización del servidor de Vue o Nuxt. Angular tiene Universal. Sin embargo, las soluciones más populares emplean alguna forma de hidratación, así que ten en cuenta el enfoque en uso antes de seleccionar una herramienta.

Renderización estática

La renderización estática ocurre durante la compilación. Este enfoque ofrece un FCP rápido y también un INP y TBT más bajos, suponiendo que la cantidad de JS del cliente es limitada. A diferencia del procesamiento del servidor, también logra un TTFB rápido y consistente, ya que el HTML de una página no tiene que generarse de forma dinámica en el servidor. Generalmente, el procesamiento estático significa generar un archivo HTML separado para cada URL con anticipación. Con las respuestas HTML generadas de antemano, los procesamientos estáticos pueden implementarse en varias CDN para aprovechar el almacenamiento en caché perimetral.

Diagrama en el que se muestra la renderización estática y la ejecución opcional de JS que afectan a FCP y TTI.

Las soluciones para el procesamiento estático vienen en todas las formas y tamaños. Las herramientas como Gatsby están diseñadas para que los desarrolladores sientan que su aplicación se renderiza de forma dinámica en lugar de generarse como un paso de compilación. Las herramientas de generación de sitios estáticos, como 11ty, Jekyll y Metalsmith, adoptan su naturaleza estática y proporcionan un enfoque más basado en plantillas.

Una de las desventajas de la renderización estática es que se deben generar archivos HTML individuales para cada URL posible. Esto puede ser desafiante o incluso inviable cuando no puedes predecir esas URLs con anticipación o para sitios con una gran cantidad de páginas únicas.

Es posible que los usuarios de React estén familiarizados con Gatsby, la exportación estática de Next.js o Navi. Todas estas funciones facilitan crear páginas con componentes. Sin embargo, es importante comprender la diferencia entre la renderización estática y la renderización previa: las páginas renderizadas estáticas son interactivas sin la necesidad de ejecutar mucho JavaScript del lado del cliente, mientras que la renderización previa mejora el FCP de una aplicación de una sola página que debe iniciarse en el cliente para que las páginas sean realmente interactivas.

Si no sabes con certeza si una solución determinada es el procesamiento estático o la renderización previa, inhabilita JavaScript y carga la página que quieras probar. En el caso de las páginas renderizadas de forma estática, la mayor parte de la funcionalidad seguirá existiendo sin JavaScript habilitado. En el caso de las páginas renderizadas previamente, es posible que existan algunas funciones básicas, como los vínculos, pero la mayor parte de la página estará inerte.

Otra prueba útil es usar la limitación de red en las Herramientas para desarrolladores de Chrome y observar cuánto se descargó JavaScript antes de que una página se vuelva interactiva. La renderización previa generalmente requiere más JavaScript para volverse interactivo, y que JavaScript tiende a ser más complejo que el enfoque de mejora progresiva que usa la renderización estática.

Comparación entre la renderización del servidor y la renderización estática

La renderización del servidor no es una solución milagrosa, ya que su naturaleza dinámica puede conllevar importantes costos de sobrecarga de procesamiento. Muchas soluciones de renderización del servidor no se vacían con anticipación, pueden retrasar el TTFB o duplicar los datos que se envían (por ejemplo, el estado intercalado que usa JavaScript en el cliente). En React, renderToString() puede ser lento, ya que es síncrono y de un solo subproceso. Las APIs más nuevas de DOM del servidor de React admiten la transmisión, que pueden obtener la parte inicial de una respuesta HTML antes en el navegador mientras el resto se sigue generando en el servidor.

Hacer que la renderización del servidor “correcta” puede implicar encontrar o compilar una solución para el almacenamiento en caché de componentes, administrar el consumo de memoria, aplicar técnicas de memorización y otros problemas. Por lo general, se procesa o vuelve a compilar la misma aplicación varias veces: una vez en el cliente y otra en el servidor. El hecho de que la renderización del servidor pueda hacer que algo aparezca antes no significa que, de repente, tengas menos trabajo por hacer. Si tienes mucho trabajo en el cliente después de que llega una respuesta HTML generada por el servidor al cliente, esto aún puede generar un TBT y un INP más altos para tu sitio web.

La renderización del servidor produce HTML a pedido para cada URL, pero puede ser más lenta que solo entregar contenido estático renderizado. Si puede realizar el trabajo adicional, la renderización del servidor y el almacenamiento en caché de HTML pueden reducir significativamente el tiempo de renderización del servidor. La ventaja de la renderización del servidor es la capacidad de extraer más datos “en vivo” y responder a un conjunto más completo de solicitudes de lo que es posible con la renderización estática. Las páginas que requieren personalización son un ejemplo concreto del tipo de solicitud que no funcionaría bien con el procesamiento estático.

La renderización del servidor también puede presentar decisiones interesantes cuando se compila una AWP: ¿es mejor usar el almacenamiento en caché de service worker de página completa o solo procesar fragmentos individuales de contenido en el servidor?

Renderización del cliente

La renderización del cliente implica procesar las páginas directamente en el navegador con JavaScript. Toda la lógica, la recuperación de datos, la creación de plantillas y el enrutamiento se manejan en el cliente y no en el servidor. El resultado efectivo es que se pasan más datos desde el servidor al dispositivo del usuario, y eso conlleva su propio conjunto de compensaciones.

La renderización del cliente puede ser difícil de obtener y mantener la velocidad para los dispositivos móviles. Si se realiza un trabajo mínimo, la renderización del cliente puede abordar el rendimiento de la renderización pura del servidor, lo que mantiene un presupuesto de JavaScript ajustado y entrega valor en la menor cantidad posible de recorridos de ida y vuelta. Las secuencias de comandos y los datos críticos se pueden entregar antes con <link rel=preload>, que hace que el analizador funcione antes. También vale la pena evaluar patrones como PRPL para garantizar que las navegaciones iniciales y posteriores se sientan instantáneas.

Diagrama que muestra la renderización del cliente que afecta a FCP y TTI.

La principal desventaja de la renderización del lado del cliente es que la cantidad de JavaScript requerida tiende a aumentar a medida que la aplicación crece, lo que puede tener efectos negativos en el INP de una página. Esto se vuelve especialmente difícil con la incorporación de nuevas bibliotecas de JavaScript, polyfills y código de terceros, que compiten por la potencia de procesamiento y, a menudo, deben procesarse antes de que se pueda renderizar el contenido de una página.

Las experiencias que usan procesamiento del cliente que dependen de grandes paquetes de JavaScript deben considerar la división agresiva de código para reducir el INP y TBT durante la carga de la página, y asegurarse de realizar una carga diferida de JavaScript: "entrega solo lo que necesitas cuando lo necesitas". En el caso de las experiencias con poca o ninguna interactividad, la renderización del servidor puede representar una solución más escalable para estos problemas.

Para quienes crean aplicaciones de una sola página, identificar las partes centrales de la interfaz de usuario que se comparten en la mayoría de las páginas significa que pueden aplicar la técnica de almacenamiento en caché de la shell de la aplicación. En combinación con los service worker, puede mejorar drásticamente el rendimiento percibido en visitas repetidas, ya que el HTML de shell de la aplicación y sus dependencias se pueden cargar desde CacheStorage muy rápidamente.

Combina la renderización del servidor y la del cliente a través de rehidratación

Con este enfoque, se intenta suavizar las compensaciones entre la renderización del cliente y la del servidor a través de ambas acciones. Las solicitudes de navegación, como las cargas o recargas de páginas completas, se administran a través de un servidor que procesa la aplicación en HTML. Luego, el JavaScript y los datos utilizados para la renderización se incorporan en el documento resultante. Cuando se hace con cuidado, esto logra un FCP rápido como la renderización del servidor y, luego, se “recoge” mediante la renderización nuevamente en el cliente mediante una técnica llamada (re)hidratación. Esta es una solución eficaz, pero puede conllevar considerables desventajas de rendimiento.

La principal desventaja de la renderización del servidor con rehidratación es que puede tener un impacto negativo significativo en el INP y el TBT, incluso si mejora el FCP. Es posible que parezca que las páginas renderizadas en el servidor están cargadas y son interactivas, pero no pueden responder a las entradas hasta que se ejecuten las secuencias de comandos del cliente para los componentes y se hayan adjuntado controladores de eventos. En dispositivos móviles, esto puede demorar segundos o incluso minutos.

Tal vez hayas experimentado esto tú mismo: durante un tiempo después de que parece que se carga una página, hacer clic o presionar no hace nada. Esto se vuelve frustrante en poco tiempo, ya que el usuario se pregunta por qué no sucede nada cuando intenta interactuar con la página.

Un problema de rehidratación: una aplicación por el precio de dos

Los problemas de rehidratación suelen ser peores que la interactividad retrasada debido a JavaScript. Para que el código JavaScript del cliente pueda “retomar” con precisión donde lo dejó el servidor sin tener que volver a solicitar todos los datos que el servidor utilizó para procesar su HTML, las soluciones de renderización actuales del servidor generalmente serializan la respuesta de las dependencias de datos de una IU en el documento como etiquetas de secuencia de comandos. El documento HTML resultante contiene un alto nivel de duplicación:

Documento HTML que contiene una IU serializada, datos intercalados y una secuencia de comandos bundle.js

Como puedes ver, el servidor muestra una descripción de la IU de la aplicación en respuesta a una solicitud de navegación, pero también muestra los datos de origen que se usaron para componer esa IU y una copia completa de la implementación de la IU que luego se inicia en el cliente. Esta IU se vuelve interactiva solo después de que bundle.js haya terminado de cargarse y ejecutarse.

Las métricas de rendimiento recopiladas de sitios web reales que usan renderización y rehidratación del servidor indican que no debe usarse. En última instancia, el motivo radica en la experiencia del usuario: es extremadamente fácil dejar a los usuarios en un "valle asombroso", en el que la interactividad se siente ausente aunque la página parezca estar lista.

Diagrama en el que se muestra la renderización del cliente que afecta negativamente al TTI.

Sin embargo, hay esperanzas para la renderización del servidor con rehidratación. En el corto plazo, solo usar la renderización del servidor para el contenido que puede almacenarse en caché puede reducir el TTFB, lo que produce resultados similares a los de la renderización previa. La rehidratación de forma progresiva, progresiva o parcial puede ser la clave para que esta técnica sea más viable en el futuro.

Renderización del servidor de transmisión y rehidratación progresiva

El procesamiento del servidor tuvo varios desarrollos en los últimos años.

La renderización del servidor de transmisión te permite enviar HTML en fragmentos que el navegador puede renderizar de forma progresiva mientras se recibe. Esto puede garantizar un FCP rápido, ya que el lenguaje de marcado llega a los usuarios con mayor rapidez. En React, las transmisiones asíncronas en [renderToPipeableStream()], en comparación con las renderToString() síncronas, significan que la contrapresión se controla bien.

También vale la pena considerar la rehidratación progresiva, y es algo que React ha obtenido. Con este enfoque, las partes individuales de una aplicación procesada por el servidor se “inician” con el tiempo, en lugar del enfoque común actual de inicializar toda la aplicación a la vez. Esto puede ayudar a reducir la cantidad de JavaScript que se requiere para que las páginas sean interactivas, ya que la actualización de las partes de la página de baja prioridad del cliente puede aplazarse para evitar que se bloquee el subproceso principal, lo que permite que las interacciones del usuario ocurran antes después de que el usuario las inicie.

La rehidratación progresiva también puede ayudar a evitar uno de los errores más comunes de rehidratación de renderización del servidor, en la que se destruye un árbol del DOM renderizado por el servidor y, luego, se vuelve a compilar de inmediato. A menudo, esto se debe a que la renderización síncrona inicial del cliente requería datos que no estaban listos, quizás esperando la resolución de un Promise.

Rehidratación parcial

La rehidratación parcial ha demostrado ser difícil de implementar. Este enfoque es una extensión de la idea de rehidratación progresiva, en la que se analizan las piezas individuales (componentes/vistas/árboles) que se rehidrataron progresivamente y se identifican las que tienen poca interactividad o ninguna reacción. Para cada una de estas partes mayormente estáticas, el código JavaScript correspondiente se transforma en referencias inertes y funcionalidad decorativa, lo que reduce su huella del cliente a casi cero.

El enfoque de hidratación parcial conlleva sus propios problemas y compromisos. Esto plantea algunos desafíos interesantes para el almacenamiento en caché, y la navegación del lado del cliente significa que no podemos suponer que el HTML procesado por el servidor para partes inertes de la aplicación estará disponible sin una carga de página completa.

Renderización trisomórfica

Si los service worker son una opción para ti, también puede resultarte interesante la renderización "trisomórfica". Es una técnica en la que puedes usar la renderización del servidor de transmisión para la navegación inicial o que no es de JS y, luego, hacer que tu service worker realice la renderización de HTML para las navegaciones una vez que se haya instalado. Esto permite mantener actualizados los componentes y las plantillas almacenados en caché y habilita las navegaciones de estilo SPA para renderizar nuevas vistas en la misma sesión. Este método funciona mejor cuando puedes compartir las mismas plantillas y código de enrutamiento entre el servidor, la página del cliente y el service worker.

Diagrama de la renderización trisomórfica en el que se muestra un navegador y un service worker comunicándose con el servidor.

Consideraciones de SEO

A menudo, los equipos tienen en cuenta el impacto de la SEO cuando eligen una estrategia de renderización en la Web. Por lo general, la renderización del servidor se elige para brindar una experiencia de apariencia "completa" que los rastreadores puedan interpretar con facilidad. Los rastreadores pueden comprender JavaScript, pero, a menudo, existen limitaciones que vale la pena tener en cuenta en cuanto a la forma en que se renderizan. La renderización del lado del cliente puede funcionar, pero a menudo no sin pruebas adicionales y trabajo en etapas. Recientemente, el procesamiento dinámico también se convirtió en una opción que vale la pena considerar si tu arquitectura depende en gran medida de JavaScript del cliente.

Si tienes dudas, la herramienta para evaluar optimización para dispositivos móviles es muy útil para comprobar si el enfoque que elegiste cumple lo que esperas. Muestra una vista previa de cómo se muestra cualquier página al rastreador de Google, el contenido HTML serializado que se encontró (después de que se ejecuta JavaScript) y los errores detectados durante el procesamiento.

Captura de pantalla de la IU de la prueba de optimización para dispositivos móviles.

Conclusión

Cuando elijas un enfoque para la renderización, mide y comprende cuáles son los cuellos de botella. Considera si la renderización estática o la del servidor pueden ayudarte a lograrlo. No hay problema si envías HTML con la cantidad mínima de JavaScript para obtener una experiencia interactiva. A continuación, se incluye una infografía útil que muestra el espectro servidor-cliente:

Infografía que muestra el espectro de opciones que se describen en este artículo.

Créditos

Gracias a todos por sus opiniones e inspiración:

Jeffrey Posnick, Hussein Djirdeh, Shahubhie Panicker, Chris Harrelson y Sebastian Markbåge