Skip to content

Blog


Améliorer la performance des pages web chez DoorDash grâce au rendu côté serveur avec Next.JS

29 mars 2022

|
Patrick El-Hage

Patrick El-Hage

Duncan Meech

Duncan Meech

Les grandes entreprises de commerce électronique sont souvent confrontées au défi d'afficher des images de produits attrayantes tout en garantissant des vitesses de chargement rapides sur les pages les plus fréquentées de leur site web. Chez DoorDash, nous avons été confrontés à ce problème parce que notre page d'accueil - le principal vecteur du succès de notre marché en ligne - était affectée par des vitesses de téléchargement lentes qui nuisaient à l'expérience de l'utilisateur et à notre classement dans les pages de référencement. 

Notre solution a consisté à mettre en œuvre le rendu côté serveur pour les pages à fort trafic, ce qui a nécessité de relever de nombreux défis. Dans cet article, nous énumérons ces défis et la manière dont nous les avons abordés afin de mettre en place un rendu côté serveur réussi et de prouver son efficacité. 

Contenu

Pourquoi le rendu côté serveur (SSR) améliore-t-il un site ? 

L'application DoorDash fonctionnait sur un système côté client sujet à des problèmes de chargement, à un mauvais référencement et à d'autres problèmes. En passant à un rendu côté serveur, nous espérions pouvoir améliorer un certain nombre d'éléments clés, notamment : 

  • Amélioration de l'expérience utilisateur : Nous voulions améliorer l'expérience de l'utilisateur en réduisant les temps de chargement des pages. Cela correspond à l'introduction récente des paramètres web de Google qui favorisent les pages rapides et légères sur les appareils mobiles modestes. Ces paramètres ont une influence significative sur le classement des pages attribué par Google.
  • Activation Optimisation de la taille des paquets : Notre application monopage rendue côté client (CSR, SPA) devenait difficile à optimiser parce que la taille du JavaScript et des autres ressources était devenue trop importante.
  • Améliorer LE RÉFÉRENCEMENT : Nous avons entrepris de fournir des métadonnées optimales pour le référencement en utilisant un contenu rendu côté serveur. Dans la mesure du possible, il est préférable de fournir aux moteurs de recherche un contenu web entièrement formé plutôt que d'attendre que le JavaScript côté client rende le contenu. Une approche : Déplacer les appels API du navigateur client (nord-sud) vers le serveur (est-ouest), où les performances sont généralement meilleures que sur l'appareil de l'utilisateur.

Pièges à éviter lors du passage à la RSS

Nous voulions éviter les problèmes courants liés à la RSS tout en nous efforçant d'obtenir ces avantages. Le rendu d'une trop grande quantité de contenu sur le serveur peut s'avérer coûteux et nécessiter un grand nombre de pods de serveur pour gérer le trafic. Notre objectif de haut niveau était de n'utiliser le serveur que pour rendre le contenu supérieur au pli ou le contenu nécessaire à des fins de référencement. Pour ce faire, il fallait s'assurer que l'état des composants du serveur et du client correspondait exactement. Les divergences entre le client et le serveur entraîneront des re-rendus inutiles du côté du client.

Mesurer les performances pour garantir le respect des critères de réussite

Nous avons utilisé webpagetest.org à la fois pour mesurer les performances des pages pré-SSR et pour confirmer les gains de performance sur les nouvelles pages SSR. Cet excellent outil permet de mesurer les pages sur une grande variété d'appareils et de conditions de réseau, tout en fournissant des informations extrêmement détaillées sur la multitude d'activités qui se produisent lors du chargement d'une page volumineuse et/ou complexe. 

Le moyen le plus fiable d'obtenir des informations sur les performances est de tester des appareils réels avec des vitesses de réseau réalistes à une distance géographique de vos serveurs. Exemple concret : Les performances d'un site web sur un MacBook Pro ne sont pas un indicateur fiable des performances réelles.

Plus récemment, nous avons ajouté le suivi des données vitales de Google (LCP, CLS, FID) à nos tableaux de bord d'observabilité afin de nous assurer que nous capturons et suivons les performances des pages à travers tout le spectre des visiteurs et des appareils.

Personnaliser Next.js pour DoorDash

De nombreux ingénieurs de DoorDash sont de grands fans de l'équipe Next.js et de Vercel. L'infrastructure de Vercel a été construite pour Next.js, offrant à la fois une expérience de développement incroyable et une infrastructure d'hébergement qui rend le travail avec Next.js facile et optimisé au maximum.


En fait, nous avons utilisé Vercel pour élaborer notre concept initial de RSS que nous avons ensuite utilisé pour présenter les parties prenantes.

Chez DoorDash, cependant, nous avions besoin d'un peu plus de flexibilité et de personnalisation que ce que Vercel pouvait offrir lorsqu'il s'agit de la façon dont nous déployons, construisons et hébergeons nos applications. Nous avons plutôt opté pour l'approche du serveur personnalisé pour servir les pages via Next.js parce qu'elle nous offrait plus de flexibilité dans la façon dont nous hébergions notre application au sein de notre infrastructure Kubernetes existante.

Figure 1 : Voici une vue simplifiée de la façon dont nous avons utilisé un proxy inverse pour diriger le trafic dans l'expérience A/B pour le rendu côté serveur.

Notre serveur personnalisé est construit avec Express.js et exploite notre boîte à outils serveur JavaScript interne, qui fournit des fonctionnalités prêtes à l'emploi telles que la journalisation et la collecte de métriques.

Au niveau de la couche d'entrée, nous avons configuré un proxy inverse qui dirige les demandes à l'aide de notre cadre d'expérimentation interne. Cette configuration nous permet d'utiliser un déploiement basé sur le pourcentage pour les consommateurs en cours de traitement. Si les consommateurs ne sont pas regroupés dans le traitement, nous acheminons leur demande vers notre application préexistante à page unique. Cette configuration de proxy nous donne de la flexibilité quant aux conditions dans lesquelles nous dirigeons le trafic.

Ce proxy gère également d'autres aspects de la mise à l'échelle, tels que la journalisation, la coupure de circuit et les délais d'attente, qui sont abordés ci-dessous.

Restez informé grâce aux mises à jour hebdomadaires

Abonnez-vous à notre blog d'ingénierie pour recevoir régulièrement des informations sur les projets les plus intéressants sur lesquels notre équipe travaille.

Mise en œuvre de la RSS sans interruption de service 

Parce que DoorDash se développe rapidement et que de nombreux développeurs travaillent en permanence, nous ne pouvons nous permettre aucun temps d'arrêt, qui perturberait l'expérience des clients et nos développeurs qui travaillent sur d'autres parties de la plateforme.

En d'autres termes, nous devions changer les roues de la voiture alors que nous roulions à toute allure sur l'autoroute.  

Nous avons soigneusement étudié comment nos efforts de migration vers Next.js affecteraient les clients, les ingénieurs et l'entreprise DoorDash dans son ensemble. Pour ce faire, nous avons dû résoudre plusieurs problèmes clés avant de pouvoir aller de l'avant.

Comment assurer l'adoption de nouvelles technologies sans arrêter ou ralentir le développement de nouvelles fonctionnalités ?

Il n'était pas possible pour nous d'arrêter le développement de nouvelles fonctionnalités - code freeze - pendant la migration de notre stack vers Next.js parce que DoorDash est comme une fusée en termes de croissance (voir Figure 2).

Figure 2 : Ce graphique montre le nombre cumulé de commandes livrées par DoorDash au cours de sa vie. DoorDash a atteint 1 milliard de commandes cumulées le 25 octobre 2020, et a doublé ce nombre de commandes à vie pour atteindre 2 milliards moins d'un an plus tard.

Si nous obligions les ingénieurs à développer leurs nouvelles fonctionnalités uniquement sur la nouvelle pile Next.js, nous risquions de bloquer leurs déploiements et leurs lancements de produits ; les clients ne pourraient pas essayer les nouvelles fonctionnalités qui améliorent leur expérience et notre entreprise ne serait pas en mesure d'itérer aussi rapidement sur de nouvelles idées.

Par conséquent, si nous demandions aux ingénieurs de développer de nouvelles fonctionnalités à la fois dans l'ancienne base de code et dans la nouvelle base de code Next.js, nous les chargerions de maintenir des fonctionnalités dans deux environnements d'exécution distincts. De plus, notre nouvelle application Next.js est elle-même en développement rapide, ce qui obligerait les développeurs à réapprendre de nombreux changements significatifs tout au long du cycle de développement.

Minimiser les frais généraux et les coûts de maintenance pendant que les deux versions d'un site servent activement le trafic.

Nous devions donc trouver un moyen de maintenir les nouvelles fonctionnalités dans les deux environnements sans exiger des ingénieurs qu'ils contribuent uniquement à la nouvelle base de code. Nous voulions nous assurer qu'ils ne soient pas ralentis ou bloqués par la migration vers Next.js.

Le fait que les piles se trouvent dans une base de code séparée n'était pas idéal car nous ne voulions pas maintenir une fourche dans une base de code séparée, ce qui aurait augmenté la charge opérationnelle et le changement de contexte pour les ingénieurs. N'oubliez pas : DoorDash se développe rapidement, avec de nouveaux contributeurs issus de différentes équipes et organisations qui se lancent à l'assaut du terrain ; toute décision ou contrainte technique qui affecte la façon dont les ingénieurs travaillent a des implications massives.

Nous avons donc fait cohabiter les deux applications dans la même base de code, en maximisant la réutilisation du code dans la mesure du possible. Pour minimiser la charge de travail des développeurs, nous avons permis aux fonctionnalités d'être développées sans se soucier de la façon dont elles s'intégreraient à Next.js et aux paradigmes SSR. Grâce à l'examen du code et à l'application de règles de linting, nous nous sommes assurés que les nouvelles fonctionnalités étaient écrites de manière à être compatibles avec le SSR. Ce processus a permis de s'assurer que les changements s'intègrent bien avec SSR indépendamment des changements effectués dans l'application Next.js.

Lors de l'unification de la base de code Next.js de la preuve de concept avec notre ancienne base de code d'application, nous avons eu besoin de prendre soin de certaines configurations de construction afin que les composants écrits pour une application soient interopérables avec l'autre application.

Une partie de ce travail d'unification a impliqué des changements d'outils de construction, y compris la mise à jour de la configuration Typescript de notre projet pour supporter isolatedModules, la mise à jour de la configuration Babel de Webpack, et la mise à jour de nos configurations Jest afin que le code écrit pour Next.js et notre application existante soient écrits de manière similaire.

Tout ce qui restait à ce stade était de migrer notre application de CSR à SSR.

Adoption progressive de la RSS sur une page existante sans réécrire chaque fonctionnalité

Nous voulions apprendre rapidement et voir des gains de performance importants pour nos clients sans passer par un effort de plusieurs trimestres pour migrer une grande application avec des douzaines de pages. La migration d'une application multi-pages entière vers Next.js aurait représenté un effort massif qui était hors de portée pour ce que nous essayions d'accomplir. 

Nous avons donc opté pour une approche d'adoption progressive, page par page, en migrant une page à la fois vers Next.js.

Nous avons adopté une stratégie "tronc-branche-feuille", qui consiste à concentrer les efforts d'optimisation sur les composants proches du haut de la page ou proches du haut de la hiérarchie des composants de la page. Par exemple, nous avons complètement réécrit l'image du héros en haut de la page d'accueil parce qu'elle était au-dessus du pli et presque au sommet de la hiérarchie des composants de la page. Les composants situés plus bas dans la page ou dans la hiérarchie n'ont pas été modifiés. Si ces composants contenaient des références à des objets non disponibles sur le serveur - tels que la fenêtre ou le document - nous avons soit opté pour un chargement paresseux sur le client, soit simplement effectué un léger remaniement pour supprimer la dépendance côté client de ces composants.

Pour permettre l'utilisation symétrique des composants du SSR, côté serveur ou côté client, et de l'application CSR SPA, nous avons introduit un fournisseur de contexte appelé AppContext. Ce fournisseur donne accès à des objets communs tels que les paramètres de la chaîne de requête, les cookies et l'URL de la page d'une manière qui fonctionne de manière transparente dans n'importe quel contexte. Sur le serveur, par exemple, les cookies sont disponibles en les analysant à partir de l'objet de requête, tandis que sur le client, ils sont disponibles en analysant la chaîne document.cookie. En enveloppant la nouvelle application SSR et l'application CSR SPA existante dans ce fournisseur, nous pourrions permettre aux composants de fonctionner dans l'une ou l'autre.

Abstraction des détails de l'implémentation et du comportement conditionnel grâce au contexte de l'application

Il existe des différences essentielles entre notre ancienne et notre nouvelle application :

  • Routage : React Router (SPA) vs routage basé sur Next.js
  • Cookies de lecture : Lecture directe d'un document ou absence de document disponible pendant le SSR
  • Suivi : Ne pas déclencher les événements de suivi pendant le SSR vs. côté client

Avec le modèle de pont, nous pouvons découpler l'implémentation de l'abstraction et modifier le comportement au moment de l'exécution en fonction de l'environnement dans lequel nous exécutons l'application.

Voici quelques exemples de la manière dont cela peut être fait avec un pseudo-code simplifié. Nous pouvons créer un contexte d'application qui stocke des métadonnées sur notre application et notre expérience :

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
}

Ensuite, nos dépendances principales peuvent lire cet état global de l'application pour se comporter de manière conditionnelle ou échanger des dépendances en fonction des besoins, comme suit :

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>
}

Chaque application est instanciée avec cet état global :

const MyOldCSRApp = () => (
 <AppContext.Provider value={{ isSSR: false }}>
   <MySharedComponent />
 </AppContext.Provider>
)
const MyNewSSRApp = () => (
 <AppContext.Provider value={{ isSSR: true }}>
   <MySharedComponent />
 </AppContext.Provider>
 )

Pendant ce temps, les composants partagés restent parfaitement ignorants de leur environnement ou des dépendances qui travaillent sous le capot :

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>
 )
}

Évolution et fiabilité des services

Nous devions nous assurer que notre nouvelle application fonctionnerait de manière fiable et sans accroc. Pour ce faire, nous devions mieux comprendre notre implémentation actuelle et préparer le système à résister à tout problème potentiel que nous pourrions rencontrer au fur et à mesure de l'augmentation du trafic vers notre service. Nous avons réalisé un déploiement fiable en utilisant les approches suivantes :

Mesure et étalonnage des performances

Avant de déployer notre nouveau service en production, nous avions besoin de connaître le volume de trafic qu'il pouvait supporter et les ressources dont il avait besoin. Nous avons utilisé des outils comme Vegeta pour vérifier la capacité actuelle d'un pod unique. Après un premier audit, nous avons constaté que tous les cœurs n'étaient pas utilisés pour répartir la charge de traitement. Nous avons donc utilisé l'API de cluster de Node.js pour utiliser tous les cœurs du pod, ce qui a quadruplé la capacité de demande du pod. 

Se replier en toute sécurité pour atténuer la dégradation du service

Ce service étant nouveau et n'ayant pas encore été mis en production, nous nous sommes rendu compte qu'il existait des risques de déploiement qu'il faudrait probablement atténuer. Nous avons décidé de faire en sorte que, si le nouveau service ne répondait pas aux demandes ou était interrompu, nous puissions revenir en douceur à l'ancienne expérience.

Comme indiqué précédemment, nous avons configuré un proxy pour gérer le routage du trafic et le regroupement des utilisateurs. Pour résoudre nos problèmes de fiabilité du déploiement, nous avons configuré le proxy pour qu'il renvoie la demande à notre ancienne application si la demande du nouveau service échoue.

Délestage et coupure de circuit

Pour éviter une surcharge du système ou une dégradation de l'expérience des clients, nous devions mettre en place des mécanismes tels que la coupure de circuit. Ces mécanismes nous permettent de traiter les demandes qui commencent à échouer en raison de problèmes d'exécution ou parce que les demandes commencent à se mettre en file d'attente, ce qui dégrade les performances.

Un disjoncteur à durée limitée nous permet de détecter la surcharge des serveurs SSR et de les court-circuiter (délestage) pour revenir au CSR.

Notre proxy était configuré avec un disjoncteur - opossum - pour délester la charge si les requêtes prenaient trop de temps à aboutir ou échouaient.

La figure 3 représente un tel disjoncteur. Selon le célèbre auteur du diagramme, Martin Fowler :

"L'idée de base du disjoncteur est très simple. L'appel d'une fonction protégée est enveloppé dans un objet disjoncteur, qui surveille les défaillances. Lorsque les défaillances atteignent un certain seuil, le disjoncteur se déclenche et tous les autres appels au disjoncteur retournent avec une erreur, sans que l'appel protégé ne soit effectué. En général, vous voudrez aussi une sorte d'alerte de surveillance si le disjoncteur se déclenche".

Figure 3 [Crédit : https://martinfowler.com/bliki/CircuitBreaker.html]
Schéma montrant les différents états d'un disjoncteur.

Tableaux de bord, suivi du RUM et de l'état de préparation opérationnelle

Pour s'assurer que notre service se comportait comme prévu, il était vital pour nous d'intégrer une observabilité et un suivi complets de l'état du système.

Nous avons mis en place des instruments de mesure et des compteurs pour des éléments tels que les taux de requêtes, les échecs, les latences et l'état des disjoncteurs. Nous avons également configuré des alertes afin d'être informés immédiatement de tout problème. Enfin, nous avons documenté des manuels d'exploitation afin de pouvoir intégrer des ingénieurs de garde au service de manière transparente pour gérer les alertes.

Faire face aux problèmes

Si notre approche était bonne, elle était loin d'être parfaite, ce qui signifie que nous avons dû faire face à quelques problèmes en cours de route. Il s'agit notamment des points suivants : 

Premier écueil : l'analyse et le suivi des mesures de réussite

Même si nous n'avons pas mis en œuvre une réécriture complète, la réécriture partielle des composants et le chargement des pages avec une nouvelle pile ont conduit à des lectures analytiques inattendues. Ce problème n'est pas spécifique à Next.js ou SSR, mais à toute migration majeure impliquant une certaine forme de réécriture. Il est essentiel de s'assurer que les mesures du produit sont collectées de la même manière pour l'ancien et le nouveau produit.

Gotcha #2 : Next.js divise et précharge de manière agressive le bundle

Nous avons utilisé le rendu côté client dans Next.js comme moyen d'améliorer les performances de rendu côté serveur, de charger paresseusement les fonctionnalités côté client inutiles et d'adopter les composants qui n'étaient pas prêts à être rendus côté serveur. Cependant, lors du chargement paresseux de ces bundles, qui utilisent la balise preload, nous avons constaté une augmentation des délais d'interactivité. L'équipe de Next.js était déjà consciente de ce problème potentiel de performance car elle travaille sur un contrôle plus granulaire du préchargement des bundles JavaScript dans une prochaine version de Next.js.

Gotcha #3 : Taille excessive du DOM lors du rendu sur le serveur

Les meilleures pratiques en matière de performances Web préconisent de maintenir une taille réduite du DOM (moins de 1 500 éléments) et une profondeur d'arbre DOM inférieure à 32 éléments avec moins de 60 éléments enfants/parents. Du côté du serveur, cette pénalité peut parfois être ressentie encore plus fortement que sur le navigateur, car le temps de réponse au premier octet est retardé par le traitement supplémentaire de l'unité centrale nécessaire à l'exécution de la requête. En retour, l'utilisateur peut attendre plus longtemps qu'il ne le souhaite en regardant un écran vide pendant le chargement de la page, ce qui annule les gains de performance escomptés que le rendu côté serveur peut apporter. Nous avons remanié certains composants et différé le chargement de certains composants pour qu'ils soient chargés paresseusement afin de réduire les frais généraux de rendu côté serveur et d'améliorer les performances.

Résultats 

La migration de nos pages vers Next.js a permis d'améliorer le temps de chargement des pages de +12% et +15% sur les pages Home et Store. Le LCP (l'une des principales mesures de vitesse de Google) s'est amélioré de 65 % sur les pages d'accueil et de 67 % sur les pages des magasins. L'indicateur principal des mauvaises URL (LCP > 4s) sur Google a chuté de 95%.

Conclusion / Résumé

Pour tous les ingénieurs qui cherchent à migrer leur stack vers Next.js, nous aimerions résumer nos principaux enseignements de l'introduction de Next.js à DoorDash :

  1. Performance : L'adoption de Next.js peut conduire à des améliorations considérables des performances du web mobile. N'hésitez pas à utiliser des outils tels que https://www.webpagetest.org/ pour vérifier les performances existantes avant et après le déploiement.
  2. Migration incrémentale: A toute équipe qui envisage de migrer son application vers Next.js, nous voulons souligner qu'une approche incrémentale peut minimiser les réécritures complètes tout en permettant aux fonctionnalités de coexister à la fois dans un ancien CSR et dans de nouvelles applications SSR Next.js.
  3. Stratégie de déploiement : Nous tenons à souligner l'importance d'une stratégie de déploiement bien définie et de mécanismes de secours pour se prémunir contre les pannes de site.
  4. Mesures de réussite : Enfin, il est important de définir clairement les paramètres de réussite et de veiller à ce que le bon suivi soit en place pour confirmer que la migration a été réussie.

À propos des auteurs

  • Patrick El-Hage

  • Duncan Meech

Emplois connexes

Localisation
San Francisco, CA; Oakland, CA
Département
Ingénierie
Localisation
Toronto, ON
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA
Département
Ingénierie
Localisation
San Francisco, CA ; Mountain View, CA ; New York, NY ; Seattle, WA
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Seattle, WA
Département
Ingénierie