Les trois quarts de mon SDK étaient du code que presque personne n'exécutait

JavaScript SDK

Quand vous intégrez un script tiers, vous dépensez la bande passante de vos utilisateurs sur la foi du jugement de quelqu'un d'autre. Pour mon propre widget, je n'y avais pas fait attention.

Le widget BugDump partait sous la forme d'un seul fichier de 450 Ko (117 Ko compressé en gzip) chez chaque visiteur de chaque page qui l'intégrait. Les lecteurs anonymes d'une page marketing le payaient. Les gens qui ne remarquaient même pas le bouton le payaient. C'est beaucoup demander pour une fonction que la plupart des visiteurs ne touchent jamais.

Alors j'ai mesuré ce qu'il y avait vraiment dedans.

La mesure

esbuild vous dit exactement où sont passés les octets d'un bundle si vous lui demandez un metafile. Voici le mien :

Octets minifiés

Part

html2canvas-pro

248 965

55,5 %

Mon propre code

117 162

26,1 %

@rrweb/record

75 064

16,7 %

@rrweb/packer

7 077

1,6 %

Soixante-douze pour cent du bundle, c'étaient deux bibliothèques, et toutes deux servaient des fonctions qui ne tournent que lorsque quelqu'un fait une action précise. html2canvas produit une capture d'écran de la page. rrweb enregistre la relecture de session. Aucune des deux n'a quoi que ce soit à faire pendant qu'un visiteur lit un article de blog.

L'idée évidente, c'est de proposer deux builds : un « complet » et un « léger », sans les fonctions lourdes. Je l'ai écartée, pour une raison qui mérite d'être dite clairement : c'est répondre au moment du build à une question qui se pose à l'exécution. html2canvas devient nécessaire au moment où un utilisateur clique sur « Capture », et aucun flag de build ne peut le prévoir. Un client sur le build complet paierait toujours 249 Ko à chaque chargement de page pour un bouton que la plupart de ses visiteurs n'utilisent jamais. Et un client sur le build léger dont le forfait inclut la relecture de session aurait une fonction qui ne fait rien, sans le moindre signe, et le code n'aurait aucun moyen de détecter l'incohérence.

La meilleure réponse est de garder un seul build et de changer le moment où les choses se chargent.

Découper un build qu'on ne peut pas découper

C'est là que ça devient intéressant. Le build pour la balise script est une IIFE, un bundle unique qui s'exécute lui-même : le format nécessaire quand la page n'a pas de chargeur de modules. esbuild ne gère le code splitting que pour une sortie ESM. splitting: true ne fait rien pour une IIFE.

Mais le splitting et l'import dynamique sont deux choses différentes. Un import() dynamique fonctionne très bien dans un script classique ; seules les instructions import/export exigent type="module". Le piège, c'est qu'esbuild résout tout ce qu'il peut au moment du build :

js
// esbuild resolves this and folds the module back into the bundle
const mod = await import('./heavy');

// esbuild cannot resolve this, so it survives as a real runtime import
const mod = await import(someUrl);

Le spécificateur doit être une valeur connue à l'exécution. C'est toute l'astuce, et c'est pour ça que personne ne remarque que l'option existe : tout le monde écrit la version littérale, voit le module se faire inliner et en conclut qu'une IIFE ne se découpe pas.

Reste une question : à l'exécution, depuis quelle URL importer ? Un script tiers ne sait pas d'où il a été servi, à moins de le demander :

js
const BASE = document.currentScript.src;   // https://bugdump.com/sdk/latest.js
const url = new URL('./bugdump-replay.js', BASE).href;
const { record } = await import(url);

C'est avec document.currentScript qu'il faut faire attention. Il n'est défini que pendant l'exécution du code de premier niveau du script. Lisez-le dans un gestionnaire de clic, et vous obtenez null. Il est donc capturé une seule fois, au niveau du module, dans une constante, et tout le reste du code utilise celle-ci.

Le résultat, avec le même build en trois fichiers :

Fichier

Compressé (gzip)

Se charge quand

Entrée

28 Ko

Sur chaque page

Chunk de capture d'écran

62 Ko

Quelqu'un prend une capture du DOM

Chunk de relecture

27 Ko

Le forfait autorise la relecture de session

Le point d'entrée est passé de 117 Ko à 28 Ko compressé. Rien n'a été supprimé.

Le bug que le découpage a révélé

En branchant le chunk de relecture, j'ai trouvé quelque chose qui était faux depuis longtemps, sans que personne ne le remarque.

La relecture de session est une fonction payante. L'ancien code démarrait l'enregistreur dès l'initialisation, puis demandait à l'API de BugDump si le forfait du projet incluait la relecture, et appelait stop() si ce n'était pas le cas. Fonctionnellement correct. Mais cela voulait dire que chaque client sans relecture de session téléchargeait 82 Ko de rrweb, démarrait un enregistreur du DOM, puis l'éteignait.

Rendre le téléchargement conditionnel à cette même réponse a réglé les deux problèmes d'un coup. Le chunk n'est désormais demandé qu'après le feu vert du serveur.

Il y a un vrai compromis : la relecture démarre un aller-retour réseau plus tard qu'avant. Je l'ai accepté, parce que l'enregistreur garde une fenêtre glissante avec des instantanés complets périodiques, et un rapport de bug ne repose pas sur les premières centaines de millisecondes de mutations du DOM. Les collecteurs de console, de réseau et d'actions sont légers, restent dans le point d'entrée et enregistrent toujours dès le premier instant.

Là où je me trompais sur defer

Je pensais que la solution pour une balise script bloquante était defer. C'est le conseil standard. Et c'est aussi, pour cet usage, un mauvais conseil.

J'ai construit une page proche de la production, bridée à 2 Mbit/s avec 100 ms de latence, et mesuré les trois variantes. Médiane de trois exécutions :

Balise script

domInteractive

DOMContentLoaded

Sans attribut

290 ms

290 ms

defer

56 ms

287 ms

async

58 ms

58 ms

defer libère le parseur : domInteractive s'effondre. Mais la spécification impose qu'un script différé s'exécute avant le déclenchement de DOMContentLoaded, donc DCL attend toujours la fin du téléchargement. 287 ms contre 290 ms, ce n'est pas un gain.

async libère les deux, parce qu'un script async a le droit de s'exécuter dès qu'il arrive, sans garantie d'ordre. C'est exactement le bon compromis pour un widget autonome : rien sur la page n'a besoin qu'il ait tourné à un moment précis.

Beaucoup d'applications accrochent leur propre démarrage à DOMContentLoaded. Si vous intégrez un script tiers avec defer en pensant l'avoir rendu non bloquant, vous avez déplacé le coût, vous ne l'avez pas supprimé.

Le snippet est désormais :

HTML
<script src="https://bugdump.com/sdk/latest.js" async data-api-key="your-api-key"></script>

Une réserve l'accompagne : si vous initialisez manuellement depuis un script inline, async signifie que le SDK n'existe peut-être pas encore. Placez l'appel dans un écouteur load : cet événement ne se déclenche qu'après l'exécution des scripts async.

Ce que j'aurais aimé me dire plus tôt

Mesurez avant de théoriser. J'aurais pu deviner pendant une semaine quelles parties pesaient lourd. Le metafile a répondu en une commande, et la réponse (72 % dans deux bibliothèques optionnelles) menait droit à la solution.

Préférez les décisions à l'exécution à celles du build. Chaque fois que vous vous surprenez à concevoir une matrice de variantes de build, vérifiez si le critère qui sépare vos variantes est vraiment connu au moment du build. Dans mon cas, non.

Un feature flag qui arrive après le téléchargement n'est pas un feature flag. Si le serveur sait déjà si un client a droit à quelque chose, cette réponse doit conditionner les octets, pas seulement le comportement.

Vérifiez les conseils, y compris les vôtres. defer est un bon conseil pour beaucoup de scripts. Ce n'en était pas un pour celui-ci, et je ne l'ai découvert que parce que j'ai mesuré au lieu de me fier aux idées reçues, les miennes comprises.


Le nouveau SDK est en ligne. Si vous le chargez depuis bugdump.com, vous l'avez déjà ; ajoutez async à votre balise quand vous en aurez l'occasion. En auto-hébergement, notez que le SDK compte désormais trois fichiers au lieu d'un, qui doivent rester dans le même répertoire : la référence de configuration du widget donne les détails.

Pour aller plus loin