Renderización en la Web

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

Addy Osmani
Addy Osmani

Como desarrolladores, a menudo nos encontramos con 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.

La forma en que entendemos este espacio se basa en nuestro trabajo en Chrome durante los últimos años al hablar 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 entender mejor las arquitecturas que elegimos cuando tomamos esta decisión, necesitamos tener un conocimiento sólido de cada enfoque y una terminología coherente que debemos utilizar cuando se habla de ellos. Las diferencias entre estos enfoques ayudan a ilustrar las compensaciones de renderizar en la Web desde el punto de vista del rendimiento.

Terminología

Renderización

  • Renderización del servidor (SSR): Se procesa una app universal o del cliente en HTML en el servidor.
  • Renderización del cliente (CSR): Renderizar 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 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 código HTML estático.

Rendimiento

Renderización del servidor

El procesamiento 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 de datos y el creación de plantillas en el cliente, ya que se manejan 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 de la página y la renderización en el servidor permiten evitar el envío de mucho JavaScript al cliente. Esto ayuda a reducir la TBT de una página, que también puede reducir el INP, ya que el subproceso principal no se bloquea con tanta frecuencia durante la carga de la página. Cuando se bloquea el subproceso principal con menos frecuencia, las interacciones del usuario 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 del navegador, como el análisis de documentos en streaming.

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

Con el procesamiento en el servidor, es menos probable que los usuarios tengan que esperar a que se ejecute JavaScript vinculado a la CPU antes de poder usar tu sitio. Incluso en los casos en que no se pueda evitar el JS de terceros, usar la renderización del servidor para reducir tus propios costos de JavaScript puede brindarte más presupuesto para el resto. Sin embargo, este enfoque tiene una posible desventaja: generar páginas en el servidor requiere 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 creando. Hace mucho tiempo que se debate sobre las aplicaciones correctas de la renderización del servidor y la 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 técnicas de representación híbrida con éxito. 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 aumenta la probabilidad de que se carguen rápidamente a las páginas que renderizan más recursos por el cliente.

Muchos frameworks, bibliotecas y arquitecturas modernos permiten renderizar la misma aplicación tanto en el cliente como en el servidor. Estas técnicas pueden usarse para la renderización del servidor. Sin embargo, es importante tener en cuenta que 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 compensaciones y características de rendimiento muy diferentes. Los usuarios de React pueden usar las 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 en el momento de la compilación. Este enfoque ofrece un FCP rápido, además de un INP y TBT más bajos (suponiendo que la cantidad de JS del cliente sea limitada). A diferencia de la renderización del servidor, también logra un TTFB rápido y coherente, ya que no es necesario que el HTML de una página se genere de forma dinámica en el servidor. Por lo general, un procesamiento estático implica producir con anticipación un archivo HTML separado para cada URL. Con las respuestas HTML generadas de antemano, los procesamientos estáticos se pueden implementar 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.

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 manera 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, lo que proporciona 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 cuáles serán 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 opciones facilitan la creación de 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 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 se debe iniciar en el cliente para que las páginas sean realmente interactivas.

Si no sabes con certeza si una solución es un procesamiento estático o una 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 aún haya algunas funciones básicas, como vínculos, pero la mayor parte de la página estará inerte.

Otra prueba útil es usar la limitación de la red en las Herramientas para desarrolladores de Chrome y observar cuánto JavaScript se descargó antes de que una página se vuelva interactiva. La renderización previa generalmente requiere más JavaScript para ser 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 generar costos altos de sobrecarga de procesamiento. Muchas soluciones de renderización del servidor no se vacian antes, pueden retrasar 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. Nuevas APIs de DOM del servidor de React compatibles con la transmisión, con las que se puede enviar la parte inicial de una respuesta HTML al navegador mucho antes mientras el resto aún se genera en el servidor.

Hacer que el procesamiento del servidor esté "correcto" puede implicar encontrar o compilar una solución para el almacenamiento en caché de componentes, administrar el consumo de memoria y aplicar técnicas de memorización, entre otras inquietudes. En general, estás procesando o volviendo 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 que hacer. Si tienes mucho trabajo en el cliente después de que llega una respuesta HTML generada por el servidor al cliente, 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 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 se puede lograr 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 renderizar en el servidor fragmentos de contenido individuales?

Renderización del cliente

El procesamiento del lado del cliente implica renderizar 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 y no en el servidor. El resultado efectivo es que se pasan más datos al dispositivo del usuario desde el servidor, lo que conlleva su propio conjunto de compensaciones.

La renderización del cliente puede ser difícil de obtener y de mantener rápida 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 limitado y proporciona valor en la menor cantidad posible de recorridos de ida y vuelta. Los datos y las secuencias de comandos esenciales se pueden entregar antes con <link rel=preload>, que hace que el analizador funcione más rápido. 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 necesaria tiende a aumentar a medida que crece una aplicación, 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.

En las experiencias que usan renderización del cliente que dependen de grandes paquetes de JavaScript, se debe considerar la división de código agresiva para reducir el TBT y el INP durante la carga de la página, y asegurarse de realizar una carga diferida de JavaScript, que "publica solo lo que necesitas en el momento oportuno". Para experiencias con poca o ninguna interactividad, la renderización del servidor puede representar una solución más escalable a estos problemas.

Para las personas que 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é del shell de aplicación. Combinado con service workers, se puede mejorar drásticamente el rendimiento percibido en visitas repetidas, ya que la aplicación shell HTML y sus dependencias se pueden cargar desde CacheStorage muy rápidamente.

Cómo combinar el procesamiento del servidor y el del cliente a través de rehidratación

Este enfoque 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 completas de páginas, se controlan a través de un servidor que procesa la aplicación en HTML y, luego, el JavaScript y los datos usados para la renderización se incrustan en el documento resultante. Cuando se hace con cuidado, esto logra un FCP rápido, al igual que la renderización del lado del servidor, y luego “recoge” volviendo a renderizar en el cliente con una técnica llamada (re)hidratación. Esta es una solución eficaz, pero puede presentar desventajas de rendimiento considerables.

La principal desventaja de la renderización del servidor con rehidratación es que puede tener un impacto negativo importante en el TBT y el INP, 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 podrán responder a las entradas hasta que se ejecuten las secuencias de comandos del cliente para los componentes y se hayan conectado los controladores de eventos. En un dispositivo móvil, esto puede tardar segundos o incluso minutos.

Quizás hayas experimentado esto tú mismo; durante un tiempo después de que parece que una página se cargó, hacer clic o presionar no tiene ningún efecto. Esto se vuelve frustrante rápidamente, ya que el usuario se pregunta por qué no sucede nada cuando intenta interactuar con la página.

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

Los problemas de rehidratación suelen ser peores que la interactividad retrasada debido a JavaScript. Para que el JavaScript del cliente pueda “retomar” con precisión donde el servidor lo dejó sin tener que volver a solicitar todos los datos que el servidor usó para procesar su HTML, las soluciones actuales de renderización 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 termina de cargarse y ejecutarse.

Las métricas de rendimiento recopiladas de sitios web reales que usan el procesamiento del servidor y la rehidratación indican que se debe desalentar su uso. En última instancia, el motivo se relaciona con la experiencia del usuario: es extremadamente fácil dejar a los usuarios en un "valle inquietante", en el que no hay interactividad, aunque la página parece estar lista.

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

Sin embargo, la renderización del servidor con rehidratación es posible. 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 incremental, 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 transmisión del servidor del servidor te permite enviar HTML en fragmentos que el navegador puede renderizar de manera progresiva cuando se recibe. Esto puede generar 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) significa que la contrapresión se controla bien.

También vale la pena considerar la rehidratación progresiva, algo que ya realizó React. 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 necesaria para hacer que las páginas sean interactivas, ya que la actualización del cliente de las partes de la página de baja prioridad puede aplazarse para evitar que se bloquee el subproceso principal, lo que permite que las interacciones del usuario ocurran antes de que el usuario las inicie.

La rehidratación progresiva también puede ayudar a evitar uno de los inconvenientes más comunes de rehidratación del procesamiento del servidor, en el que se destruye un árbol del DOM renderizado por el servidor y, luego, se vuelve a compilar de inmediato (la mayoría de las veces, porque la renderización síncrona inicial del cliente requería datos que no estaban listos, tal vez a la espera de 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 rehidratarán de forma progresiva 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 funcionalidades decorativas, lo que reduce su huella del cliente a casi cero.

El enfoque de hidratación parcial tiene 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 renderizado por el servidor para partes inertes de la aplicación estará disponible sin una carga completa de la página.

Renderización trisomórfica

Si los service workers son una opción para ti, el procesamiento "trisomórfico" también puede ser de interés. 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 se encargue del procesamiento del HTML para la navegación después de 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 enfoque funciona mejor cuando puedes compartir el mismo código de plantillas y 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

Los equipos suelen considerar el impacto de la SEO cuando eligen una estrategia para renderizar en la Web. A menudo, la renderización del servidor se elige para ofrecer una experiencia "completa" que los rastreadores puedan interpretar con facilidad. Es posible que los rastreadores comprendan JavaScript, pero, a menudo, existen limitaciones que vale la pena tener en cuenta respecto de su renderización. La renderización del lado del cliente puede funcionar, pero a menudo no sin pruebas adicionales y trabajo preliminar. Más 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 con lo que esperas. Se muestra una vista previa de cómo aparece cada página en el rastreador de Google, el contenido HTML serializado que se encuentra (después de que se ejecuta JavaScript) y los errores que se encuentran durante el procesamiento.

Captura de pantalla de la IU de 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 lograr ese objetivo. No hay problema si envías principalmente HTML con una cantidad mínima de JavaScript para lograr una experiencia interactiva. A continuación, verás una infografía útil en la que se muestra el espectro servidor-cliente:

Infografía en la que se muestra la variedad de opciones que se describen en este artículo.

Créditos

Gracias a todos por sus opiniones e inspiración:

Jeffrey Posnick, Houssein Djirdeh, Shubhie Panicker, Chris Harrelson y Sebastian Markbåge