Renderización en la Web

Una de las decisiones principales que deben tomar los desarrolladores web es dónde implementar la lógica y el procesamiento en su aplicación. Esto puede ser difícil porque hay muchas maneras de crear un sitio web.

Nuestro conocimiento sobre este espacio se basa en el trabajo que realizamos en Chrome con sitios grandes durante los últimos años. 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 una comprensión sólida de cada enfoque y una terminología coherente para usar cuando hablemos sobre ellas. Las diferencias entre los enfoques de renderización ayudan a ilustrar las ventajas del procesamiento en la Web desde la perspectiva del rendimiento de la página.

Terminología

Renderización

Renderización del servidor (SSR)
Renderizar una app universal o del cliente como HTML en el servidor
Renderización del cliente (CSR)
Renderizar una app en un navegador mediante JavaScript para modificar el DOM
Rehidratación
“Iniciando” vistas de JavaScript en el cliente para que vuelvan a usar los datos y el árbol del DOM del HTML renderizado por el servidor
Renderización previa
Ejecutar una aplicación del cliente en el tiempo de compilación para capturar su estado inicial como HTML estático.

Rendimiento

Tiempo hasta el primer byte (TTFB)
El tiempo transcurrido entre que se hace clic en un vínculo y se carga el primer byte de contenido en la página nueva.
Primer procesamiento de imagen con contenido (FCP)
El momento en que se hace visible el contenido solicitado (cuerpo del artículo, etc.)
Interaction to Next Paint (INP)
Es una métrica representativa que evalúa si una página responde con rapidez y de manera coherente a las entradas del usuario.
Tiempo de bloqueo total (TBT)
Es una métrica proxy para INP que calcula por cuánto tiempo se bloqueó el subproceso principal durante la carga de la página.

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 la creación de plantillas de datos en el cliente, ya que el procesador los controla antes de que el navegador obtenga una respuesta.

Por lo general, la renderización del servidor produce un FCP rápido. Ejecutar la lógica y el procesamiento de la página en el servidor te 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 del usuario tienen 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 una variedad de condiciones de dispositivos y redes, y ofrece optimizaciones interesantes del navegador, como el análisis de documentos de transmisión.

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

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 puedas evitar el JS de terceros, usar la renderización del servidor para reducir tus costos de JavaScript propios puede brindarte 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 aumentar el TTFB de tu página.

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 renderización del servidor y del procesamiento del cliente, pero siempre puedes elegir 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. Por ejemplo, el servidor de Netflix procesa sus páginas de destino relativamente estáticas mientras realiza una carga previa del JS para las páginas con mucha interacción, lo que da a estas páginas más pesadas procesadas por el cliente una mayor oportunidad de cargarse rápidamente.

Muchos frameworks, bibliotecas y arquitecturas modernos te permiten renderizar la misma aplicación en el cliente y en el servidor. Puedes usar estas técnicas para la renderización del servidor. Sin embargo, las arquitecturas en las que la renderización se realiza en el servidor y 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 en ellas, como Next.js para la renderización del servidor. Los usuarios de Vue pueden usar la guía de renderización del servidor de Vue o Nuxt. Angular tiene Universal. Sin embargo, las soluciones más populares usan alguna forma de hidratación, por lo que debes tener en cuenta los enfoques que usa tu herramienta.

Renderización estática

La renderización estática ocurre en el tiempo de compilación. Este enfoque ofrece un FCP rápido y también un INP y TBT más bajo, siempre que limites la cantidad de JS del cliente en tus páginas. A diferencia del procesamiento del servidor, también logra un TTFB rápido y de manera constante, ya que no es necesario que el código HTML de una página se genere de forma dinámica en el servidor. En general, la renderización estática implica producir con anticipación un archivo HTML separado para cada URL. Con las respuestas HTML generadas con anticipación, puedes implementar renderizaciones estáticas en varias CDN para aprovechar el almacenamiento en caché perimetral.

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

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 y no se genera 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 del procesamiento estático es que debe 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, que facilitan la creación de páginas a partir de componentes. Sin embargo, el procesamiento estático y el procesamiento previo funcionan de manera diferente: las páginas renderizadas de forma estática son interactivas sin necesidad de ejecutar mucho JavaScript 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 un procesamiento estático o la renderización previa, inhabilita JavaScript y carga la página que desees probar. En el caso de las páginas renderizadas de forma estática, la mayoría de las funciones interactivas todavía existen sin JavaScript. Es posible que las páginas renderizadas previamente aún tengan algunas funciones básicas, como los vínculos con JavaScript inhabilitado, pero la mayor parte de la página está inerte.

Otra prueba útil es usar la regulación de red en las Herramientas para desarrolladores de Chrome y ver cuánto se descarga JavaScript antes de que una página se vuelva interactiva. En general, la renderización previa necesita más JavaScript para ser interactivo, y que JavaScript tiende a ser más complejo que el enfoque de mejora progresiva que se usa en 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 la mejor solución para todo, ya que su naturaleza dinámica puede generar costos significativos de sobrecarga de procesamiento. Muchas soluciones de procesamiento del servidor no vacían anticipadamente, retrasan TTFB ni duplican los datos que se envían (por ejemplo, estados intercalados que usa JavaScript en el cliente). En React, renderToString() puede ser lento porque es síncrono y de un solo subproceso. Las APIs más nuevas del DOM del servidor de React admiten la transmisión, que puede enviar antes la parte inicial de una respuesta HTML al navegador mientras el resto se sigue generando en el servidor.

Hacer que la renderización del servidor sea “correcta” puede implicar encontrar o compilar una solución para el almacenamiento en caché de componentes, administrar el consumo de memoria y usar técnicas de memorización, entre otros problemas. A menudo, procesas o vuelves a compilar la misma app dos veces, una en el cliente y otra en el servidor. La renderización del servidor que muestra contenido más pronto no necesariamente implica que tengas menos trabajo. Si tienes mucho trabajo en el cliente después de que llega al cliente una respuesta HTML generada por el servidor, esto puede generar una 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 renderizado estático. Si puedes agregar el trabajo adicional, la renderización del servidor y el almacenamiento en caché 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 de solicitudes más completo que lo que es posible con la renderización estática. Las páginas que necesitan personalización son un ejemplo concreto del tipo de solicitud que no funciona 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, las plantillas y el enrutamiento se controlan en el cliente, en lugar de hacerlo en el servidor. El resultado efectivo es que se pasan más datos desde el servidor al dispositivo del usuario, lo que conlleva su propio conjunto de compensaciones.

La renderización del cliente puede ser difícil de realizar y mantenerla rápida para los dispositivos móviles. Con un poco de trabajo para mantener un presupuesto de JavaScript ajustado y entregar valor en la menor cantidad de viaciones de ida y vuelta posible, puedes lograr que la renderización del cliente replique casi el rendimiento de la renderización pura del servidor. Puedes hacer que el analizador funcione más rápido si entregas secuencias de comandos y datos fundamentales con <link rel=preload>. También te recomendamos que consideres usar 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.
FCP y TTI con renderización del cliente.

La principal desventaja de la renderización del cliente es que la cantidad de JavaScript requerida tiende a aumentar a medida que la aplicación crece, lo que puede afectar el INP de una página. Esto se vuelve muy 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 la página.

Las experiencias que usan procesamiento del cliente y 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, así como la carga diferida de JavaScript para entregar solo lo que el usuario necesita cuando sea necesario. En el caso de las experiencias con poca o ninguna interactividad, la renderización del servidor puede representar una solución más escalable a estos problemas.

Para quienes compilan 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 les permite aplicar la técnica de almacenamiento en caché de la shell de la aplicación. En combinación con los service workers, esto puede mejorar drásticamente el rendimiento percibido en visitas repetidas, ya que la página puede cargar su HTML de shell de aplicación y las dependencias desde CacheStorage muy rápidamente.

La rehidratación combina la renderización del servidor y del cliente

La rehidratación es un enfoque que intenta suavizar las compensaciones entre la renderización del cliente y la del servidor mediante 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 y, luego, el JavaScript y los datos utilizados para el procesamiento 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 “retoma” volviendo a renderizar en el cliente. Esta es una solución eficaz, pero puede tener desventajas de rendimiento considerables.

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. Las páginas renderizadas en el servidor pueden parecer interactivas y estar cargadas, pero no pueden responder a la entrada hasta que se ejecuten las secuencias de comandos del lado del cliente para los componentes y se hayan conectado los controladores de eventos. En dispositivos móviles, esto puede demorar minutos, lo que puede confundir y frustrar al usuario.

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

Para que el código JavaScript del cliente "retome" con precisión donde lo dejó el servidor, sin volver a solicitar todos los datos con los que el servidor renderizó su HTML, la mayoría de las soluciones de renderización del servidor serializan la respuesta de las dependencias de datos de una IU como etiquetas de secuencia de comandos en el documento. Debido a que esto duplica una gran cantidad de HTML, la rehidratación puede causar más problemas que solo la interactividad retrasada.

Documento HTML
 que contiene una IU serializada, datos intercalados y una secuencia de comandos de bundle.js
Hay código duplicado en el documento HTML.

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. La IU no se vuelve interactiva hasta 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 rara vez es la mejor opción. El motivo más importante es su efecto en la experiencia del usuario, cuando una página parece estar lista, pero ninguna de sus funciones interactivas funciona.

Diagrama que muestra la renderización del cliente que afecta negativamente al TTI.
Los efectos de la renderización del cliente en TTI.

Sin embargo, hay esperanzas para la renderización del servidor con rehidratación. A 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 la renderización previa. La rehidratación de forma incremental, progresiva o parcial podría ser la clave para que esta técnica sea más viable en el futuro.

Transmite la renderización del servidor 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 permite que el lenguaje de marcado llegue a los usuarios más rápido, lo que acelera el FCP. En React, las transmisiones asíncronas en renderToPipeableStream(), en comparación con renderToString() síncronas, significa que la contrapresión se controla bien.

También vale la pena considerar la rehidratación progresiva, y React la implementó. 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 necesario para que las páginas sean interactivas, ya que te permite postergar la actualización de las partes de baja prioridad del cliente para evitar que 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 ayudarte a evitar uno de los inconvenientes más comunes de la rehidratación de renderización del servidor: se destruye un árbol del DOM renderizado por el servidor y, luego, se vuelve a compilar de inmediato, con mayor frecuencia porque la renderización síncrona inicial del cliente requería datos que no estaban listos, a menudo un Promise que aún no se resolvió.

Rehidratación parcial

La rehidratación parcial ha demostrado ser difícil de implementar. Este enfoque es una extensión de la rehidratación progresiva que analiza partes individuales de la página (componentes, vistas o árboles) y, luego, identifica las partes con poca interactividad o sin reacción. Para cada una de estas partes mayormente estáticas, el código JavaScript correspondiente se transforma en referencias inertes y funciones decorativas, 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, considera la renderización trisomórfica. Es una técnica que te permite usar la renderización del servidor de transmisión para las navegaciones iniciales o que no son de JS y, luego, hacer que tu service worker realice la renderización de HTML para las navegaciones una vez que se haya instalado. De esta manera, se pueden mantener actualizados los componentes y las plantillas almacenados en caché, y habilitar las navegaciones de estilo SPA para renderizar vistas nuevas en la misma sesión. Este método funciona mejor cuando puedes compartir el mismo código de plantilla y 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.
Diagrama de cómo funciona la renderización trisomórfica.

Consideraciones de SEO

A la hora de elegir una estrategia de renderización web, los equipos suelen considerar el impacto de la SEO. La renderización del servidor es una opción popular para ofrecer una experiencia "de apariencia completa" que los rastreadores pueden interpretar. Los rastreadores pueden comprender JavaScript, pero, a menudo, existen limitaciones en la forma en que se renderizan. La renderización del cliente puede funcionar, pero a menudo necesita pruebas y sobrecarga adicionales. 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 del código JavaScript del cliente.

Si tienes dudas, la herramienta de prueba de optimización para dispositivos móviles es una excelente manera de comprobar si el enfoque que elegiste hace lo que esperas. Muestra una vista previa de cómo aparece una página para el rastreador de Google, el contenido HTML serializado que encuentra después de que se ejecuta JavaScript y los errores que se producen durante la renderización.

Captura de pantalla de la IU de la prueba de optimización para dispositivos móviles.
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 renderización del servidor pueden ayudarte a lograrlo. Está bien enviar HTML con JavaScript mínimo para obtener una experiencia interactiva. A continuación, verás 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.
Opciones de renderización y sus compensaciones.

Créditos

Gracias a todos por sus opiniones e inspiración:

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