Résolution des démarrages à froid de Cal.com

Contexte
Pendant un certain temps, l’application Cal.com pouvait subir de longs temps de chargement au démarrage à froid, allant de 7 secondes à 30 secondes dans les cas extrêmes. Ce comportement ne se produisait pas toujours, en raison de la nature des démarrages à froid, mais il se produisait assez fréquemment pour devenir un problème que nous devions résoudre dès que possible.
Pour celles et ceux qui ne connaissent pas les démarrages à froid, il existe déjà beaucoup de ressources qui décrivent le problème de manière générale, donc nous n’allons pas revenir dessus. Avec cet article, nous voulons expliquer la cause exacte qui affectait notre système en particulier, causée par les démarrages à froid dans un environnement serverless.
Constatations et expériences
Pour commencer, regardons le log suivant de Vercel :

Pour la fonction /api/trpc/[trpc], nous observons une Cold Boot Duration de 298 ms mais une Execution Duration de 7,57 s. C’est un peu étrange, non ? Le démarrage à froid en lui-même n’est pas le problème ; c’est ce qui se charge pendant ce démarrage à froid qui fait fortement gonfler la Execution Duration.
Alors, par où commencer pour trouver la cause racine de cela ?
Hypothèse 1 : taille du bundle de la fonction
Après avoir vu la Function Size de 33,1 MB, notre équipe, la communauté ainsi que les équipes de Vercel et de Prisma se sont directement penchées sur le sujet et ont commencé des analyses de bundle et des logs de compilation pour essayer de trouver quels packages alourdissaient nos fonctions. Même si cette Function Size est inférieure à la limite de 50 MB, nous pensions qu’elle pouvait quand même être au moins en partie en cause.
Nous avons bien identifié quelques dépendances volumineuses chargées dans le cadre de la récupération de notre session, que nous avons pu alléger (PR), et nous avons chargé paresseusement les dépendances de notre App Store (PR).
Les performances se sont améliorées à chaque requête après ces mises à jour, mais nous n’avons pas obtenu la réduction de plusieurs secondes que nous espérions. Alors, qu’est-ce que cela pouvait être d’autre ? Prisma est-il lent à se connecter au démarrage à froid dans notre configuration serverless ?
Hypothèse 2 : Prisma
Nous faisions déjà tourner Prisma Data Proxy en production pour gérer notre pool de connexions, donc nous étions sceptiques quant au fait que ce soit le problème, mais nous voulions quand même faire preuve de diligence raisonnable pour nous en assurer.
Dans une branche expérimentale, nous avons mis en place un endpoint de test séparé de nos routes API et de nos pages dans une route app distincte (en utilisant le nouvel App Router de Next.js) qui ne récupérait que le nombre d’utilisateurs dans le système. Les performances de cette route au démarrage à froid pouvaient atteindre 1 seconde, ce qui nous a montré que Prisma peut avoir un léger délai au démarrage à froid, mais là encore, ce n’était pas les 7 à 15 secondes que nous constations en moyenne.
Hypothèse 3 : _app et _document
À partir de l’analyse du bundle qui avait été effectuée, il était clair que notre application charge beaucoup de dépendances. Cela a donné l’idée de réduire ce qui était chargé à chaque requête API. Sachant que les routes API de Next.js chargent le fichier _app.tsx, nous avons décidé d’implémenter un PageWrapper qui contient toutes les dépendances nécessaires pour les routes non API (PR).
Là encore, c’est une belle amélioration en termes d’optimisation des dépendances, mais nous avions toujours nos longs temps d’attente.
Cause racine
Après avoir publié une série de PR axées sur les performances qui ont aidé à l’organisation du code, à la vitesse et à la taille du bundle, nous n’avions toujours pas trouvé la solution miracle. Il était temps de revenir à la case départ et de penser en termes de goulots d’étranglement, puisque de nombreux problèmes de performance y sont liés, n’est-ce pas ?
En suivant ce raisonnement, un test a été réalisé pour séparer le routeur tRPC le plus simple dans sa propre route API. Pour nous, c’était notre routeur public. Après le déploiement, l’endpoint /api/trpc/viewer.public.i18n est devenu /api/trpc/public/i18n. Nous avons constaté une baisse du temps de démarrage à froid de 15 secondes à 2 secondes pour cette route. Cela semblait indiquer que notre routeur tRPC unique était bien le goulot d’étranglement.
En résumé, nous avions trop de routeurs tRPC regroupés dans le routeur principal viewer exposé sur la route /api/trpc/[trpc]. Cela signifiait que le tout premier appel à cette fonction, qu’il s’agisse de charger des termes i18n ou d’obtenir l’agenda d’un utilisateur, devait importer les 20 routeurs ainsi que toutes leurs dépendances.
Comme on peut le voir ici dans ce qui a finalement été la PR qui a résolu la majeure partie de nos problèmes de longs démarrages à froid, nous soupçonnions que le routeur slots était trop volumineux et qu’il était à l’origine des problèmes. Malheureusement, après avoir séparé le routeur slots dans sa propre route API Next.js, nous constations toujours des tailles de bundle importantes et de longs temps de démarrage à froid. Pour ces raisons, cette PR est restée en l’état pendant que d’autres hypothèses étaient explorées.
Après la découverte du chargement de la route i18n en 2 secondes lors des démarrages à froid, la PR mentionnée ci-dessus a ensuite été enrichie afin que tous les routeurs tRPC aient leur propre route API. Une fois séparés, Vercel crée des fonctions distinctes par route.
Alors, pourquoi le fait de séparer les routeurs tRPC dans leurs propres routes API Next.js résout-il le problème ? La réponse simple, dans notre cas, est que même si une page appelle 5 routes tRPC différentes pour charger des données, elles s’exécutent en parallèle et l’effet du chargement des dépendances de tous les routeurs ne s’additionne donc pas. Nous pouvons désormais encore voir des temps de démarrage à froid de 2 à 3 secondes pour n’importe quelle route tRPC donnée, mais comme elles sont appelées en parallèle par le navigateur, le temps d’attente total peut être de 3 secondes, et non de 7 à 15 secondes (ou 30 secondes dans les cas extrêmes).
Nous savons qu’il reste encore du travail pour réduire encore davantage ces temps, mais les réductions obtenues jusqu’ici ont été significatives.
Voici une issue pour promouvoir d’éventuelles évolutions de tRPC dans les environnements serverless.
Résultats clés

Remerciements
Un grand bravo et un immense merci à toutes les personnes qui ont contribué à résoudre ce problème. Qu’il s’agisse d’écrire des scripts personnalisés ayant aidé à déterminer quelles dépendances ralentissaient notre système ou d’ouvrir des PR, nous apprécions vraiment le soutien et les efforts consacrés à la résolution de ces problèmes. Nous espérons que cet article et les PR pourront vous donner un aperçu des expériences que nous avons menées et de la cause racine que nous avons finalement trouvée.
Un merci particulier à Julius Marminge de tRPC.io, Guillermo Rauch, Malte Ubl et Jimmy Lai de Vercel ainsi qu’à Lucas Smith. 🙏
Et ensuite
Même si c’est une avancée majeure, nous travaillons toujours d’arrache-pied pour réduire encore davantage les démarrages à froid, mais aussi pour améliorer l’expérience de self-hosting sans architecture serverless. Restez à l’écoute !
