Je vous emmène dans les entrailles de V8 pour comprendre comment optimiser au mieux tout le code javascript que vous écrivez à longueur de journées.
On y est. Je vais enfin vous parler des navigateurs et de V8.
Les navigateurs web sont ma passion depuis, pfiou… Et quand j’ai commencé à m’intéresser à comment fonctionne un navigateur, j’ai voulu comprendre comment les pages étaient chargées et du coup, qu’est-ce qui faisait tourner tout ce JS qu’on écrit à longueur de journées.
Pourquoi c’est intéressant (au-delà de la connaissance pour la connaissance) ?
Je mets quelques exercices “pratiques” ou choses à aller visualiser directement chez vous, je trouve ca plus fun et je vous encourage à ouvrir un navigateur pour tester et à créer un petit projet js sur node pour les tests V8.
Avant de nous plonger dans V8, je trouve important de revoir rapidement certaines bases, pour avoir quelques notions en tête (si vous savez comment marche un navigateur et ce qu'est un JS engine, passez directement à la section 2).
Un navigateur, c’est une application sur votre ordinateur. Quand on lance un navigateur, il se passe ce qui se passe quand on lance n’importe quelle application: on utilise le processus et les threads de notre ordinateur, du GPU et du CPU (le processeur, aka le cerveau de votre ordi).
Exercice pratique
Ce qui est intéressant, c'est que les navigageurs n'ont pas d'architecture standard. Un peu comme il existe des applications différentes pour lire vos mails (mail, gmail, yahoo), il existe différentes applications de navigateur et non seulement ils sont différents entre eux, mais ils sont aussi différents dans le temps car il évoluent vite : certains ont un seul processus avec des threads différents, d’autres ont plusieurs processus avec des threads qui communiquent entre eux, etc.
Le + de Dre Drey
Revenons à comment s’affiche une page:
BAM ca fait une page web (presque).

Je reviens donc sur la différence web engine et js engine comme le montre superbement bien ce graphique: le navigateur utilise un JS engine. Attention donc à ne pas mélanger les torchons et les serviettes comme dirait ma grand-mère, par exemple pour Chrome (promis, j’ai pas d’actions chez eux):
Le moteur javascript, c’est ce qui permet au code javascript de s’exécuter. C’est une des pièces du puzzle du navigateur, mais qui peut être utilisée ailleurs que dans un navigateur. V8 est une facon d’executer du code js, mais il en existe d’autres: SpiderMonkey (utilisé dans les navigateurs Firefox), Nitro utilisé chez Safari, etc. Tous ces moteurs sont écrits différemment et parfois dans des langages différents (donc avec des performances, des avantages, etc. différents). Et comme un js engine permet d’executer du code JS, il peut être utilisé dans plein d’autres choses qu’un navigateur: ainsi V8 est utilisé pour nodejs, dans VsCode, etc.
Ca serait très long de détailler toutes les architectures de tous les navigateurs avec les spécificités de tous les moteurs javascript. C’est pourquoi j’ai décidé de me concentrer plus particulièrement sur V8 pour cet article.
Pourquoi s’intéresser à V8 plutôt que SpiderMonkey, donc ? Glad you ask ! V8 est vraiment intéressant :
Bon, en fait je vous mens un peu, car en soi le gros de l'architecture et la JIT ne sont pas des éléments ultra distinctifs aujourd'hui: tous les moteurs JavaScript modernes font du JIT. SpiderMonkey (Firefox) utilise plusieurs tiers JIT avec WarpMonkey, JavaScriptCore (Safari) a une architecture similaire à 4 tiers, et même l'ancien Chakra (Edge) le faisait. La compilation JIT n'est absolument pas distinctive de V8. Mais ca, combiné à la documentation publique et à l'adoption massive, fait que V8 est un excellent cas d'étude pour comprendre les moteurs JavaScript en général.
"Audrey c'est quoi cet acronyme que tu balances depuis tout à l'heure, JIT ?", me direz-vous ?
Javascript est normalement un langage interprété, mais en fait, il est aussi compilé. La JIT, just in time compilation, accélère l’exécution du code, car comme son nom l’indique, il combine de l’interpretation et de la AOT (ahead of time) compilation. En gros, ca veut dire que le moteur va “lire” le script javascript et faire un mix entre l’interpréter et le compiler en langage machine à la volée.
Ce “mix” a commencé en 2015-2016 avec le pipeline composé de TurboFan (le compilateur) et d'Ignition (l'interpréteur, initialement pour réduire la mémoire utilisée par les téléphones Android). En 2021-2023, ce pipeline d’origine Ignition-TurboFan s’enrichit de 2 compilateurs supplémentaires pour arriver à un pipeline à 4 tiers :
Ignition (interpréteur) → Sparkplug (baseline compiler) → Maglev (mid-tier compiler) → TurboFan (top-tier compiler)
Le + de Dre Drey
Comment ca marche de faire à la fois de la compilation et de l'interprétation ?
Le + de Dre Drey
Ce qui est pratique dans le JIT du coup, c’est que l’interprétation et la compilation communiquent: via deux grands moyens, la dé-optimisation et la recompilation.
En fait, on compile le code, on le run, on collecte des informations, on recompile avec ces infos par exemple sur le type, on se dit "ouh cette fonction est importante en fait, recompilons-la plus optimisée". Quand on fait ca, on part du principe que cette fonction va utiliser les mêmes types… mais c’est possible que non : du coup si cette fonction run sur d’autres types d’objets, on va faire entrer la déoptimisation
Si TurboFan optimise du code en supposant qu'une fonction reçoit toujours des nombres, et qu'à un moment on lui passe une string, boom : déoptimisation. V8 jette le code optimisé et retourne à un tier inférieur (Maglev, Sparkplug, ou Ignition). On en reparle plus bas
On arrive au coeur du sujet: comment V8 fait-il pour que du JavaScript dynamique tourne aussi vite ? V8 utilise plusieurs techniques d'optimisation pour accélérer l'exécution du code JavaScript. Voici quelques-unes des plus importantes, avec des exemples pratiques que vous pouvez tester dans votre navigateur :
Dans votre tête, vous imaginez probablement un objet JavaScript comme un dictionnaire : { nom: "valeur", autre: 42 }. Et techniquement, c'est vrai... mais ce n'est pas comme ça que V8 les stocke.
Les recherches dans un dictionnaire sont relativement lentes. Pour rendre rapide l'accès aux propriétés, V8 fait semblant que JavaScript a des classes. Quand vous créez un objet, V8 crée une "hidden class" derrière les rideaux qui stocke les méta-informations sur la structure de l'objet
Regardons ce qui se passe :
function Point(x, y) {
this.x = x;
this.y = y;
}
const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
Étape par étape :
p1 est créé, V8 crée une hidden class vide (appelons-la C0)this.x = x s'exécute, V8 crée une nouvelle hidden class C1 qui dit "j'ai une propriété x à l'offset 0"this.y = y s'exécute, V8 crée C2 qui dit "j'ai x à l'offset 0 et y à l'offset 1"p2 est créé, V8 réutilise la chaîne C0 → C1 → C2 : p1 et p2 partagent la même hidden class finale !Ces "Hidden Classes", ou "shapes", forment des chaînes de transition dans le moteur JavaScript. L'objet commence sans propriétés (shape vide), puis le moteur transite vers une shape qui contient la propriété 'x', puis vers une autre shape qui contient 'x' et 'y'. V8 connait donc "la structure" de l'objet.
Pourquoi c'est génial ? Parce que si V8 sait que p1 et p2 ont la même hidden class (la même structure en gros), il sait que p1.x et p2.x sont au même endroit en mémoire. Pas besoin de faire une recherche : on va direct à l'offset 0.
Le problème :
const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
p2.z = 5;
p2 ne partage plus la même shape que p1. Les optimisations qui étaient basées sur "tous les Points ont la même structure" tombent à l'eau...
Exercice pratique
L'inline caching est une technique d'optimisation où V8 observe que les appels répétés à la même opération tendent à utiliser le même type d'objets. V8 va donc "cacher" le résultat du lookup de propriété.
Imaginons ce code :
function getX(point) {
return point.x;
}
getX(p1);
getX(p2);
getX(p3);
La première fois que getX est appelé :
pointpoint, chercher où est x, etc.Les fois suivantes :
Un inline cache (IC) peut devenir Monomorphic, Polymorphic, ou Megamorphic au fil du temps, selon combien de shapes d'objets différentes il rencontre:
Exemple concret :
function getX(point) {
return point.x;
}
// Monomorphic : tous les points ont la même shape
const goodPoints = [];
for (let i = 0; i < 1000000; i++) {
goodPoints.push({x: i, y: i \* 2}); // Toujours x puis y
}
// Polymorphic : shapes différentes !
const badPoints = [];
for (let i = 0; i < 1000000; i++) {
if (i % 2) {
badPoints.push({x: i, y: i _ 2}); // Shape A
} else {
badPoints.push({y: i _ 2, x: i}); // Shape B (ordre différent!)
}
}
Certains articles que j'ai lus parlent de différence x56 (l'article de Matteo Malvica par exemple). Je nuancerais un peu (je n'ai pas reproduit ces chiffres avec mes petits exemples) : peut-être que sur des classes complexes ou du code plus long/complexe, la différence est plus flagrante, mais il faut aussi savoir que V8 dans ses dernières versions a beaucoup amélioré la gestion des inline caches polymorphiques et megamorphiques, donc la différence de performance peut varier selon la version de V8 et le contexte d'exécution.
Exercice pratique
On a vu que V8 optimise agressivement le code. Mais que se passe-t-il quand ses suppositions sont fausses ?
Quand votre code brise les suppositions de V8, en créant des objets avec des ordres de propriétés différents, en mélangeant des types dans des arrays, ou en utilisant des patterns dynamiques, etc. vous déclenchez un “performance cliff”. V8 est forcé de jeter le code machine optimisé dans un processus appelé déoptimisation et revenir à un tier inférieur.
Exemple classique :
function add(x, y) {
return x + y;
}
// V8 voit toujours des nombres
for (let i = 0; i < 10000; i++) {
add(i, i + 1);
}
// TurboFan optimise : "add fait de l'addition d'entiers"
// Puis vous mettez des strings
add("hello", "world"); // -> DÉOPTIMISATION`
V8 avait généré du code machine spécialisé pour l'addition d'entiers. Quand il reçoit des strings, il doit :
Les déoptimisations causent généralement des ralentissements de 2x à 20x selon le contexte. Dans des boucles serrées ou des chemins très hot, la pénalité peut être plus sévère (potentiellement 100x ou plus).
L'enfer de la déoptimisation arrive quand une fonction est optimisée et déoptimisée beaucoup pendant l'exécution. Après quelques cycles, V8 va marquer la fonction comme non-optimisable.
Imaginez :
add() pour des nombres → TurboFanAprès quelques cycles comme ça, V8 abandonne : "cette fonction est impossible à optimiser, je ne vais plus essayer". Et vous vous retrouvez coincé avec une fonctin add lente même si vous ne l'utilisez plus qu'avec des nombres...
Exercice pratique
Certains patterns JavaScript rendent V8 très malheureux. En voici quelques-uns :
1. L'opérateur delete
Quand vous utilisez delete, vous ne retirez pas juste une propriété. Vous changez complètement la structure de l'objet d'une façon que V8 ne peut pas optimiser avec une simple transition de hidden class. Utiliser delete force V8 à passer les propriétés de l'objet en mode Dictionary, où les propriétés sont stockées dans une structure de hash map lente.
// Pas ouf :
const cache = {};
cache.user1 = { name: "Alice" };
cache.user2 = { name: "Bob" };
delete cache.user1; // tout le cache passe en dictionary mode
// Mieux :
cache.user1 = undefined; // ou null
2. Propriétés ajoutées après initialisation
// Polymorphic nightmare
function Person(name) {
this.name = name;
}
const p1 = new Person("Alice");
const p2 = new Person("Bob");
p1.age = 30; // p1 a une shape différente de p2
p1.city = "Paris"; // encore une autre shape
// Solution: en faire directement un monomorphic
function Person(name, age, city) {
this.name = name;
this.age = age || null;
this.city = city || null;
}
Exercice pratique
3. Ordre des propriétés incohérent
On l'a vu avec l'exemple {x, y} vs {y, x}. L'ordre compte, je ne reviens pas la-dessus.
4. Try/catch (mais ça s'améliore)
Historiquement, avoir un try/catch dans une fonction empêchait TurboFan de l'optimiser. Les versions récentes de V8 ont beaucoup amélioré ça, mais ça reste un point de vigilance.
Maintenant qu'on comprend les mécanismes, voici les règles d'or :
Initialisez les propriétés d'objets dans le même ordre pour qu'une hidden class puisse être partagée. Toujours appelez les fonctions avec le même type d'objets pour que l'inline caching puisse être utilisé.
// V8 est heureux:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
// V8 est heureux:
function createPoint(x, y) {
return { x, y }; // toujours dans le même ordre
}
// V8 est malheureux:
const mixedArray = [1, "deux", { trois: 3 }, null, undefined];
// V8 est heureux:
const numbers = [1, 2, 3, 4, 5];
const strings = ["un", "deux", "trois"];
delete sur du code hotOn l'a dit : delete = dictionary mode = mort de la perf. Mettez undefined ou null à la place.
Même si certaines sont null au début. Ça fixe la shape une bonne fois pour toutes.
V8 récompense le code prévisible et stable avec des shapes d'objets consistantes, et punit le code dynamique et imprévisible. Concentrez-vous sur écrire du JavaScript "ennuyeux" et monomorphic qui garde les compilateurs optimisants heureux
Le code qui change de type, qui ajoute des propriétés à la volée, qui mute des structures... c'est flexible, c'est "JavaScript-y", mais c'est lent. Le code ennuyeux, répétitif, prévisible ? C'est rapide.
On ne peut pas parler de V8 sans évoquer le Garbage Collector (GC), le système qui nettoie automatiquement la mémoire. Mais contrairement à ce qu'on pense, comprendre comment il fonctionne peut vous aider à écrire du code plus performant.
V8 divise son tas mémoire (heap) en deux générations principales : la Young Generation (où les nouveaux objets sont alloués) et la Old Generation (où les objets qui ont survécu plusieurs cycles de GC sont promus).
La Young Generation se divise elle-même en deux :
Pourquoi cette séparation ? L'hypothèse générationnelle : la plupart des objets meurent jeunes. Pensez aux variables temporaires dans une boucle, aux objets retournés par une fonction, etc. Ils sont créés, utilisés, puis deviennent inutiles presque immédiatement.
V8 utilise deux garbage collectors : le Scavenger qui collecte fréquemment la young generation, et le Mark-Sweep-Compact qui collecte le heap complet incluant old et young generation.
Le Scavenger (Minor GC) :
Le Mark-Sweep-Compact (Major GC) :
Orinoco est le nom de code du projet GC de V8 (on est d'accord que ca fait cocorico ? Mais je m'égare...). Le Scavenger parallèle a réduit le temps total de GC young generation sur le thread principal d'environ 20%-50% selon les workloads selon plusieures sources ici et aussi ici.
Ce que ça signifie concrètement :
1. Les objets short-lived sont gratuits (ou presque)
Étant donné la structure générationnelle du heap V8, les objets low-living sont en réalité assez cheap d'un point de vue GC, puisque nous payons principalement pour les objets qui survivent.
// Bon : objets temporaires dans une boucle
function process() {
for (let i = 0; i < 1000; i++) {
const temp = { x: i, y: i * 2 }; // mort à la fin de l'itération
doSomething(temp);
}
}
2. Évitez les fuites mémoire
Un nœud DOM est dit "detached" quand il est retiré du DOM tree mais que du JavaScript le référence encore. Les nœuds DOM détachés sont une cause commune de fuites mémoire.
// Fuite mémoire
let cache = [];
function addElement() {
const div = document.createElement("div");
document.body.appendChild(div);
cache.push(div); // div reste en mémoire même après remove
}
document.body.removeChild(div);
// Nettoyez vos références
cache = [];
Exercice pratique
3. Ne gardez pas de références inutiles
// Event listeners non nettoyés
element.addEventListener('click', handleClick);
// si element est retiré du DOM, handleClick le garde vivant
// Nettoyez
element.removeEventListener('click', handleClick);`
Vous me direz, "oui mais tout ton article parle de Javascript, Audrey, aujourd'hui on fait du TypeScript qui s'occupe de typer et de rendre le code optimisé". Bah bof : TypeScript disparait à la compilation, donc le code qui tourne dans V8, ca reste du Javascript pur.
TypeScript "ne rend pas V8 heureux" de lui-même, par contre, il peut nous aider à écrire du code qui rend V8 heureux : en typant les objets, par exemple, on peut garantir que touts les instances de cet objet ont les mêmes propriétés dans le même ordre. On peut aussi éviter l'exemple ci-dessus de l'enfer de la déoptimisation en typant les arguments des fonctions pour éviter de prendre des strings dans des fonctions qui attendent des numbers.
Mais Typescript n'est utile que si bien utilisé. Si vous écrivez:
function add(a: number | string, b: number | string) {
return a + b;
}
Ca marche très bien en TypeScript, il ne va rien te dire, le code compile sans erreur. Mais ça sera exactement le même scénario qu'observé ci-dessus: le type union va engendrer un comportement polymorphique au niveau de V8.
De même, any annule complètement la protection: TypeScript ne se plaint pas (enfin il peut, voire il devrait mais malheureusement ça ne tient qu'à toi), mais V8 se retrouve dans le pire cas possible.
Un bon usage de TypeScript et un code V8-friendly sont les deux faces de la même pièce. Quand tu écris du TypeScript rigoureux (pas de any, pas d'union types larges sur des hot paths, des interfaces précises), tu obtiens presque gratuitement du JavaScript que V8 peut optimiser agressivement. Mais TypeScript seul ne garantit rien.
Au final, peu importe l'outil, c'est l'intention derrière qui compte :).
Je n'ai pas pondu cet article de moi-même et, en toute humilité, ce que j'ai dit ici a déjà été dit et écrit par d'autres, d'autres manières, avec différents angles. Voici les principales contributions qui m'ont aidé à comprendre V8 et à écrire cet article et que je vous encourage fortement à allre consulter si vous voulez continuer à creuser le sujet: