Las grandes empresas de comercio electrónico a menudo se enfrentan al reto de mostrar imágenes atractivas de los productos y, al mismo tiempo, garantizar velocidades de carga rápidas en las páginas de alto tráfico de su sitio web. En DoorDash, nos enfrentamos a este problema porque nuestra página de inicio -el vehículo principal para el éxito de nuestro mercado online- estaba plagada de velocidades de descarga lentas que estaban perjudicando la experiencia del usuario y nuestras clasificaciones de páginas SEO.
Nuestra solución fue implementar el renderizado del lado del servidor para páginas de alto tráfico, lo que supuso superar numerosos retos. En este artículo, enumeramos esos retos y cómo los abordamos para construir con éxito la renderización del lado del servidor y demostrar su eficacia.
Contenido
- Por qué el renderizado del lado del servidor (SSR) mejora un sitio web
- Errores comunes que hay que evitar al pasar a la RSS
- Medición del rendimiento para garantizar el cumplimiento de los parámetros de éxito
- Personalización de Next.js para DoorDash
- Implantación del SSR sin tiempo de inactividad
- Cómo garantizar la adopción de nuevas tecnologías sin detener o ralentizar el desarrollo de nuevas funciones
- Minimizar los gastos generales y de mantenimiento mientras las dos versiones de un sitio están sirviendo activamente al tráfico.
- Adopción gradual de SSR en una página existente sin tener que reescribir todas las funciones.
- Ampliación y fiabilidad del servicio
- Lidiando con gotchas
- Resultados
- Conclusión / Resumen
Por qué el renderizado del lado del servidor (SSR) mejora un sitio web
La aplicación DoorDash se ejecutaba en un sistema cliente propenso a problemas de carga, SEO deficiente y otros problemas. Al pasar a la renderización del lado del servidor, esperábamos poder mejorar una serie de elementos clave, entre ellos:
- Mejorar la experiencia del usuario: Queríamos mejorar la experiencia del usuario acortando los tiempos de carga de las páginas. Esto coincide con la reciente introducción de las métricas web de Google que favorecen las páginas rápidas y ligeras en dispositivos móviles modestos. Estas métricas tienen una influencia significativa en la clasificación de páginas asignada por Google.
- Activación de Optimización del tamaño de los paquetes: Nuestra aplicación existente de una sola página renderizada en el lado del cliente (CSR, SPA) se estaba volviendo difícil de optimizar porque el tamaño de los paquetes de JavaScript y otros recursos se había hinchado.
- Mejorar SEO: Nos propusimos ofrecer metadatos SEO óptimos utilizando contenidos renderizados del lado del servidor. Siempre que sea posible, es mejor entregar a los motores de búsqueda contenidos web completamente formados en lugar de esperar a que JavaScript del lado del cliente renderice el contenido. Un enfoque: Trasladar las llamadas a la API del navegador del cliente (norte-sur) al lado del servidor (este-oeste), donde el rendimiento suele ser mejor que en el dispositivo del usuario.
Errores comunes que hay que evitar al pasar a la RSS
Queríamos ser cuidadosos para evitar problemas comunes con SSR mientras trabajábamos para lograr estos beneficios. Renderizar demasiado contenido en el servidor puede ser costoso y requerir un gran número de servidores para gestionar el tráfico. Nuestro objetivo a alto nivel era utilizar el servidor sólo para renderizar el contenido de la mitad superior de la página o el contenido necesario para fines de SEO. Para ello era necesario garantizar que el estado entre los componentes del servidor y del cliente coincidiera exactamente. Las discrepancias entre el cliente y el servidor darían lugar a repeticiones innecesarias de la renderización en el lado del cliente.
Medición del rendimiento para garantizar el cumplimiento de los parámetros de éxito
Utilizamos webpagetest.org tanto para medir el rendimiento de las páginas anteriores a la RSS como para confirmar el aumento de rendimiento de las nuevas páginas RSS. Esta excelente herramienta permite medir las páginas en una gran variedad de dispositivos y condiciones de red, al tiempo que proporciona información extremadamente detallada sobre la multitud de actividades que tienen lugar cuando se carga una página grande y/o compleja.
La forma más fiable de obtener información sobre el rendimiento es realizar pruebas con dispositivos reales con velocidades de red realistas a una distancia geográfica de tus servidores. Un ejemplo: El rendimiento de un sitio web en un MacBook Pro no es un indicador fiable del rendimiento en el mundo real.
Más recientemente, hemos añadido el seguimiento de Google web vitals (LCP, CLS, FID) a nuestros paneles de observabilidad para asegurarnos de que estamos capturando y supervisando el rendimiento de la página en todo el espectro de visitantes y dispositivos.
Personalización de Next.js para DoorDash
Muchos ingenieros de DoorDash son grandes fans del equipo de Next.js y de Vercel. La infraestructura de Vercel fue construida para Next.js, proporcionando tanto una increíble experiencia para desarrolladores como una infraestructura de alojamiento que hace que trabajar con Next.js sea fácil y esté optimizado al máximo.
De hecho, utilizamos Vercel para crear nuestra prueba de concepto inicial de SSR, que luego utilizamos para presentarla a las partes interesadas.
En DoorDash, sin embargo, necesitábamos un poco más de flexibilidad y personalización de lo que Vercel podía ofrecer en lo que se refiere a cómo desplegar, construir y alojar nuestras aplicaciones. En su lugar, optamos por el enfoque de servidor personalizado para servir páginas a través de Next.js porque nos proporcionaba más flexibilidad a la hora de alojar nuestra aplicación en nuestra infraestructura Kubernetes existente.
Nuestro servidor personalizado está construido con Express.js y aprovecha nuestro propio conjunto de herramientas de servidor JavaScript, que proporciona funcionalidades listas para usar como el registro y la recopilación de métricas.
En nuestra capa de entrada, configuramos un proxy inverso que dirige las solicitudes utilizando nuestro marco de experimentación interno. Esta configuración nos permite utilizar un despliegue basado en porcentajes para los consumidores en tratamiento. Si los consumidores no están agrupados en el tratamiento, dirigimos su solicitud a nuestra aplicación preexistente de una sola página. Esta configuración de proxy nos da flexibilidad sobre las condiciones en las que dirigimos el tráfico.
Este proxy también se encarga de otras cuestiones relacionadas con el escalado, como el registro, la interrupción de circuitos y los tiempos de espera, que se tratan a continuación.
Manténgase informado con las actualizaciones semanales
Suscríbase a nuestro blog de ingeniería para recibir actualizaciones periódicas sobre los proyectos más interesantes en los que trabaja nuestro equipo.
Introduzca una dirección de correo electrónico válida.
Gracias por suscribirse.
Implantación del SSR sin tiempo de inactividad
Debido a que DoorDash está creciendo rápidamente y tiene muchos desarrolladores trabajando en todo momento, no podemos permitirnos ningún tiempo de inactividad, lo que interrumpiría la experiencia del cliente y a nuestros desarrolladores trabajando en otras partes de la plataforma.
En otras palabras, necesitábamos cambiar las ruedas del coche mientras íbamos por la autopista a 65 millas por hora.
Consideramos cuidadosamente cómo nuestros esfuerzos de migración a Next.js afectarían a los clientes, a los ingenieros y al negocio de DoorDash en su conjunto. Eso significaba resolver varios problemas clave antes de poder seguir adelante.
Cómo garantizar la adopción de nuevas tecnologías sin detener o ralentizar el desarrollo de nuevas funciones
No nos resultaba factible detener el desarrollo de nuevas funciones -congelación del código- mientras migrábamos nuestra pila a Next.js porque DoorDash es como un cohete en términos de crecimiento (véase la Figura 2).
Si obligábamos a los ingenieros a desarrollar sus nuevas funciones únicamente en la nueva pila Next.js, corríamos el riesgo de bloquear sus despliegues y lanzamientos de productos; los clientes no podrían probar nuevas funciones que mejoraran la experiencia y nuestra empresa no podría iterar tan rápido sobre nuevas ideas.
En consecuencia, si exigimos a los ingenieros que desarrollen nuevas funciones tanto en la base de código antigua como en la nueva base de código Next.js, les estaríamos cargando con el mantenimiento de funciones en dos entornos de ejecución distintos. No sólo eso, sino que nuestra nueva aplicación Next.js se encuentra en un estado de desarrollo rápido, lo que exigiría a los desarrolladores volver a aprender muchos cambios significativos a lo largo del ciclo de vida de desarrollo.
Minimizar los gastos generales y de mantenimiento mientras las dos versiones de un sitio están sirviendo activamente al tráfico.
Esto nos obligó a buscar la forma de mantener las nuevas funciones en ambos entornos sin exigir a los ingenieros que contribuyeran únicamente a la nueva base de código. Queríamos asegurarnos de que no se vieran ralentizados o bloqueados por la migración a Next.js.
Tener pilas en una base de código separada no era lo ideal porque no queríamos mantener una bifurcación en una base de código separada, lo que aumentaría la sobrecarga operativa y el cambio de contexto para los ingenieros. No lo olvidemos: DoorDash está creciendo rápidamente, con nuevos colaboradores de diferentes equipos y organizaciones golpeando el suelo corriendo; cualquier decisión técnica o restricción que afecte a cómo operan los ingenieros tiene implicaciones masivas.
Por lo tanto, hicimos que ambas aplicaciones convivieran en la misma base de código, maximizando la reutilización del código siempre que fuera posible. Para minimizar la sobrecarga de los desarrolladores, permitimos la creación de funciones sin preocuparnos de cómo se integrarían finalmente con Next.js y los paradigmas SSR. Mediante la revisión del código y la aplicación de reglas de linting, nos aseguramos de que las nuevas funciones se escribieran de forma que fueran compatibles con SSR. Ese proceso garantizaba que los cambios se integraran bien con SSR independientemente de los cambios realizados en la aplicación Next.js.
Al unificar la base de código de la prueba de concepto de Next.js con la base de código de nuestra antigua aplicación, tuvimos que ocuparnos de algunas configuraciones de compilación para que los componentes escritos para una aplicación fueran interoperables con la otra.
Parte de este trabajo de unificación implicó cambios en las herramientas de construcción, incluida la actualización de la configuración de Typescript de nuestro proyecto para admitir isolatedModules, la actualización de la configuración de Babel de Webpack y la actualización de nuestras configuraciones de Jest para que el código escrito para Next.js y nuestra aplicación existente se escribieran de forma similar.
Todo lo que quedaba en esta etapa era migrar nuestra aplicación de CSR a SSR.
Adopción gradual de SSR en una página existente sin tener que reescribir todas las funciones.
Queríamos aprender rápido y ver grandes mejoras de rendimiento para nuestros clientes sin tener que realizar un esfuerzo de varios trimestres para migrar una aplicación grande con docenas de páginas. Migrar toda una aplicación multipágina a Next.js habría supuesto un esfuerzo enorme que estaba fuera del alcance de lo que queríamos conseguir.
Por lo tanto, optamos por un enfoque de adopción incremental página a página, en el que migramos una página a Next.js cada vez.
Adoptamos una estrategia de "tronco-rama-hoja", que consiste en centrar los esfuerzos de optimización en los componentes cercanos a la parte superior de la página o cercanos a la parte superior de la jerarquía de componentes de la página. Por ejemplo, reescribimos por completo el componente de la imagen principal en la parte superior de la página de inicio porque estaba por encima del pliegue y casi en la cima de la jerarquía de componentes de la página. Los componentes situados más abajo en la página o más abajo en la jerarquía se dejaron intactos. Si estos componentes contenían referencias a objetos no disponibles en el servidor, como window o document, optamos por cargarlos de forma perezosa en el cliente o simplemente realizamos una ligera refactorización para eliminar su dependencia del lado del cliente.
Para permitir el uso simétrico de componentes en SSR, tanto del lado del servidor como del lado del cliente, y la aplicación CSR SPA, hemos introducido un proveedor de contexto llamado AppContext. Este proveedor da acceso a objetos comunes como parámetros de cadena de consulta, cookies y URL de página de una forma que funciona de forma transparente en cualquier contexto. En el servidor, por ejemplo, las cookies están disponibles analizándolas desde el objeto de solicitud, mientras que en el cliente están disponibles analizando la cadena document.cookie. Envolviendo tanto la nueva aplicación SSR como la aplicación CSR SPA existente en este proveedor, podríamos permitir que los componentes funcionen en cualquiera de ellas.
Abstracción de detalles de implementación y comportamiento condicional mediante el contexto de la aplicación
Hay algunas diferencias fundamentales entre nuestra antigua aplicación y la nueva:
- Enrutamiento: Enrutamiento basado en React (SPA) frente a enrutamiento basado en Next.js
- Lectura de cookies: Lectura directa del documento frente a la ausencia de documento durante el SSR
- Seguimiento: No disparar eventos de seguimiento durante SSR vs. del lado del cliente
Con el patrón puente, podemos desacoplar la implementación de la abstracción y cambiar el comportamiento en tiempo de ejecución en función del entorno en el que estamos ejecutando la aplicación.
He aquí algunos ejemplos de cómo hacerlo con un pseudocódigo simplificado. Podemos crear un contexto de aplicación que almacene algunos metadatos sobre nuestra aplicación y experiencia:
const AppContext = React.createContext<null | { isSSR: boolean }>(null)
const useAppContext = () => {
const ctx = React.useContext(AppContext)
if (ctx === null) throw Error('Context must be initialized before use')
return ctx
}
Entonces nuestras dependencias centrales pueden leer de este app-state global para comportarse condicionalmente o intercambiar dependencias dependiendo de la necesidad como sigue:
const useTracking = () => {
const { isSSR } = useAppContext()
return {
track(eventName: string) {
// do a no-op while server-side rendering
if (isSSR && typeof window === 'undefined') return
// else do something that depends on `window` existing
window.analytics.track(eventName, {})
},
}
}
import { Link as ReactRouterLink } from 'react-router-dom'
import NextLink from 'next/link'
// Abstracting away React-Router leads to more flexibility with routing
// during migration:
const WrappedLink: React.FC<{ to: string }> = ({ to, children }) => {
const { isSSR } = useAppContext()
if (!isSSR) {
return <ReactRouterLink to={to}>{children}</ReactRouterLink>
}
return <NextLink href={to}>{children}</NextLink>
}
Cada aplicación se instanciará con este estado global:
const MyOldCSRApp = () => (
<AppContext.Provider value={{ isSSR: false }}>
<MySharedComponent />
</AppContext.Provider>
)
const MyNewSSRApp = () => (
<AppContext.Provider value={{ isSSR: true }}>
<MySharedComponent />
</AppContext.Provider>
)
Mientras tanto, los componentes compartidos permanecen felizmente inconscientes de su entorno o de las dependencias que trabajan bajo el capó:
const MySharedComponent = () => {
const { track } = useTracking()
return (
<div>
<p>Hello world</p>
<WrappedLink to="/home">Click me to navigate</WrappedLink>
<button onClick={() => track('myEvent')}>Track event</button>
</div>
)
}
Ampliación y fiabilidad del servicio
Necesitábamos asegurarnos de que nuestra nueva aplicación funcionaría de forma fiable y sin contratiempos. Para ello, necesitábamos comprender mejor nuestra aplicación actual y preparar el sistema para soportar cualquier posible problema que pudiéramos encontrar a medida que aumentaba el tráfico de nuestro servicio. Conseguimos un despliegue fiable utilizando los siguientes métodos:
Medición y evaluación comparativa
Antes de desplegar nuestro nuevo servicio en producción, necesitábamos saber cuánto tráfico podía soportar y qué recursos necesitaba. Utilizamos herramientas como Vegeta para auditar la capacidad actual de un pod individual. Tras una auditoría inicial, vimos que no se utilizaban todos los núcleos para repartir la carga de procesamiento. Como resultado, utilizamos la API de clúster de Node.js para hacer uso de todos los núcleos del pod, lo que cuadruplicó la capacidad de petición del pod.
Retroceder con seguridad para mitigar la degradación del servicio
Como este servicio era nuevo y aún no se había puesto en producción, nos dimos cuenta de que había riesgos de lanzamiento que probablemente habría que mitigar. Decidimos asegurarnos de que, si el nuevo servicio fallaba en las solicitudes o se agotaba el tiempo de espera, pudiéramos volver sin problemas a la experiencia anterior.
Como ya hemos mencionado, hemos configurado un proxy para que se encargue de enrutar el tráfico y agrupar a los usuarios. Para resolver nuestras preocupaciones en torno a un despliegue fiable, configuramos el proxy para enviar la solicitud de vuelta a nuestra antigua experiencia de aplicación si la solicitud del nuevo servicio fallaba.
Desconexión de la carga y corte del circuito
Para evitar la sobrecarga del sistema o que los clientes experimenten una experiencia de aplicación degradada, necesitábamos contar con mecanismos como la interrupción del circuito. Estos mecanismos garantizan que podamos gestionar las solicitudes que empiezan a fallar por problemas de tiempo de ejecución o porque las solicitudes empiezan a ponerse en cola, lo que degrada el rendimiento.
A limited timeout circuit breaker allows us to detect overloading on SSR servers and short-circuit -- load shed — to fall back to CSR.
Nuestro proxy estaba configurado con un disyuntor -opossum- para que se cargara si las peticiones tardaban demasiado en completarse o fallaban.
La figura 3 muestra un disyuntor de este tipo. Según el famoso autor del diagrama, Martin Fowler:
“The basic idea behind the circuit breaker is very simple. You wrap a protected function call in a circuit breaker object, which monitors for failures. Once the failures reach a certain threshold, the circuit breaker trips, and all further calls to the circuit breaker return with an error, without the protected call being made at all. Usually you'll also want some kind of monitor alert if the circuit breaker trips.”
Cuadros de mando, supervisión de RUM y disponibilidad operativa
Para asegurarnos de que nuestro servicio se comportaba como esperábamos, era vital que pudiéramos observar y controlar el estado del sistema.
Instrumentamos métricas y contadores para cosas como tasas de peticiones, fallos, latencias y estado de los disyuntores. También configuramos alertas para que se nos notificara inmediatamente cualquier problema. Por último, documentamos los manuales de funcionamiento para poder incorporar sin problemas ingenieros de guardia al servicio para gestionar cualquier alerta.
Lidiando con gotchas
Aunque nuestro planteamiento era bueno, no era en absoluto perfecto, lo que significaba que teníamos que lidiar con algunas pegas por el camino. Por ejemplo:
Gotcha nº 1: Análisis y seguimiento de las métricas de éxito
Aunque no hemos llevado a cabo una reescritura completa, la reescritura parcial de componentes y la carga de páginas con una nueva pila han dado lugar a lecturas inesperadas de los análisis. Este problema no es específico de Next.js o SSR, sino de cualquier migración importante que implique algún tipo de reescritura. Es fundamental asegurarse de que las métricas del producto se recopilan de forma similar tanto para el producto antiguo como para el nuevo.
Problema nº 2: Next.js divide y precarga paquetes de forma agresiva
Hemos utilizado la renderización del lado del cliente en Next.js para mejorar el rendimiento de la renderización del lado del servidor, cargar de forma perezosa funciones innecesarias del lado del cliente y adoptar componentes que no estaban listos para ser renderizados del lado del servidor. Sin embargo, al cargar de forma perezosa estos paquetes, que utilizan la etiqueta preload, observamos un aumento de los retrasos en la interactividad. El equipo de Next.js ya era consciente de este posible problema de rendimiento porque están trabajando para abordar un control más granular sobre la precarga de paquetes de JavaScript en una futura versión de Next.js.
Problema nº 3: Tamaño excesivo del DOM al renderizar en el servidor
Las mejores prácticas de rendimiento web recomiendan mantener un tamaño de DOM pequeño, inferior a 1.500 elementos, y una profundidad de árbol DOM inferior a 32 elementos con menos de 60 elementos hijos/parientes. En el lado del servidor, esta penalización puede notarse a veces incluso más que en el navegador, ya que el tiempo transcurrido hasta el primer byte se retrasa debido al procesamiento adicional de la CPU necesario para procesar la solicitud. A su vez, el usuario puede estar esperando más tiempo del deseado viendo una pantalla en blanco mientras se carga la página, contrarrestando las ganancias de rendimiento esperadas que el renderizado del lado del servidor puede proporcionar. Hemos refactorizado algunos componentes y diferido la carga de algunos componentes para que se carguen perezosamente con el fin de reducir la sobrecarga de renderizado del lado del servidor y mejorar el rendimiento.
Resultados
La migración de nuestras páginas a Next.js ha mejorado en un 12% y un 15% el tiempo de carga de las páginas de Inicio y Tienda. LCP (una de las principales métricas de velocidad de Google) ha mejorado un 65% en Home y un 67% en las páginas de tienda. El indicador principal de URL deficientes (LCP > 4s) en Google ha caído un 95%.
Conclusión / Resumen
Para cualquier ingeniero que desee migrar su pila a Next.js, nos gustaría resumir nuestros principales puntos de partida de la introducción de Next.js en DoorDash:
- Rendimiento: Adoptar Next.js puede suponer enormes mejoras en el rendimiento de la web móvil. Utiliza herramientas como https://www.webpagetest.org/ para auditar el rendimiento antes y después de la implementación.
- Migración incremental: Para cualquier equipo que esté considerando migrar su aplicación a Next.js, queremos enfatizar que un enfoque incremental puede minimizar las reescrituras completas al tiempo que permite que las características coexistan tanto en un CSR antiguo como en las nuevas aplicaciones SSR de Next.js.
- Estrategia de despliegue: Queremos insistir en la importancia de contar con una estrategia de despliegue definida y mecanismos de seguridad para protegerse de las interrupciones del servicio.
- Métricas de éxito: Por último, la importancia de contar con métricas de éxito claramente definidas y garantizar el seguimiento adecuado para confirmar que la migración se ha realizado correctamente.