Des articles approfondis sur les technologies qui façonnent l'avenir.

Quand WebAssembly n'est pas plus rapide que JavaScript

WebAssembly n'est pas toujours plus rapide que JavaScript. Benchmarks réels : quand TypeScript bat WASM et pourquoi franchir la frontière coûte cher.

Coureur bloqué à un poste frontière pendant qu'un autre file librement

Le mois dernier, j'ai passé un samedi à réécrire en TypeScript un parseur de métadonnées d'images écrit en Rust et compilé en WASM. La version WASM tournait en production depuis huit mois. Elle fonctionnait bien, personne ne s'en plaignait. Mais je fixais nos traces de performance et quelque chose me gênait : le parseur WASM passait plus de temps à transférer les données à travers la frontière JS-WASM qu'à parser réellement. Alors je l'ai réécrit. Du TypeScript pur, sans WASM. Résultat : 38 % plus rapide sur notre charge médiane. Je n'avais pas écrit de meilleurs algorithmes. J'avais simplement arrêté de payer la taxe du franchissement de frontière.

Cette expérience m'a fait voir les choses autrement. Je faisais partie de ces développeurs qui pensaient que WebAssembly était, point final, l'option rapide. En réalité, cette hypothèse se révèle fausse plus souvent que nous ne le pensons.

Le mythe « WASM est toujours plus rapide » doit disparaître

Voici le modèle mental que la plupart des développeurs ont en tête : un langage compilé part en WASM, WASM tourne à une vitesse proche du natif, donc WASM est plus rapide que JavaScript. Chaque maillon de cette chaîne est à peu près vrai. Mais la conclusion ne tient pas, parce qu'elle ignore le coût de tout ce qui entoure le calcul : faire entrer les données, en sortir les résultats, et le surcoût de faire tourner deux runtimes côte à côte.

Les moteurs JavaScript sont d'une efficacité redoutable. V8, SpiderMonkey et JavaScriptCore représentent des décennies de travail d'optimisation financées par certaines des entreprises les plus riches de la planète. Ils pratiquent la compilation JIT spéculative, le inline caching, l'escape analysis et les transitions de hidden classes. Pour de nombreuses charges de travail, surtout les schémas très orientés chaînes, objets et callbacks typiques des applications web, un moteur JS moderne génère du code machine étonnamment proche de ce qu'un compilateur statique produirait.

WASM ne bénéficie pas de ces optimisations. Il est compilé AOT : ce que vous livrez est ce qui s'exécute. C'est un atout en matière de prévisibilité, mais cela signifie que WASM ne peut pas s'adapter aux schémas d'exécution comme le fait un JIT.

Le problème de la frontière : mourir de mille appels

C'est le point le plus important. Chaque appel de JavaScript vers WASM (ou l'inverse) a un coût. Le moteur doit sérialiser les données, valider les types et changer de contexte d'exécution. Un seul franchissement est peu coûteux, de l'ordre de quelques microsecondes. Mais les charges de travail qui traversent la frontière des milliers de fois par opération en sont écrasées.

Je vois ce schéma tout le temps : une équipe écrit un parseur, un transformateur ou un validateur en Rust, le compile en WASM, puis l'enveloppe dans du code de liaison JavaScript qui appelle WASM pour chaque nœud, chaque token, chaque étape. Le cœur WASM peut être très rapide isolément, mais le code de liaison en fait une autoroute à péage.

// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source);          // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) {                     // JS land
wasmParser.transform(node, newValue);      // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.

J'ai benchmarké exactement ce scénario sur une base de code réelle. La version WASM a traité un lot de 100 fichiers de configuration en 340 ms. Le portage TypeScript l'a fait en 195 ms. Non pas parce que TypeScript est un langage plus rapide (ce n'est pas le cas), mais parce que le travail n'a jamais quitté un seul contexte d'exécution.

Pourquoi les chaînes de caractères détruisent les performances de WASM

WASM travaille sur une mémoire linéaire, c'est-à-dire un buffer plat d'octets. Les chaînes JavaScript sont une tout autre bête. Ce sont des objets gérés par le moteur, stockés dans des formats internes spécifiques (Latin1, UTF-16, ropes, cons strings). Faire passer une chaîne de JS vers WASM implique de l'encoder en UTF-8, d'allouer de l'espace dans la mémoire linéaire et de copier les octets. Pour récupérer une chaîne, il faut copier à nouveau puis la décoder.

Pour du code de calcul numérique, ça ne pose pas problème. Vous passez un TypedArray, vous récupérez des nombres. Mais pour tout ce qui manipule beaucoup de chaînes (parseurs, moteurs de templates, validateurs, sérialiseurs), vous payez la taxe encoder-copier-traiter-copier-décoder sur chaque fragment de chaîne.

// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.

J'ai profilé notre parseur WASM et constaté que 31 % du temps d'exécution total était consacré à la sérialisation des chaînes. Ni le parsing ni la construction de l'arbre : seulement le va-et-vient des chaînes à travers la frontière. La version TypeScript a éliminé toute cette catégorie de travail.

Si votre chemin critique manipule beaucoup de chaînes, WASM le rend probablement plus lent, pas plus rapide. Le surcoût de sérialisation est bien réel et il s'accumule très vite.

Compilation JIT : l'avantage de performance dont personne ne parle

Le modèle AOT de WASM garantit des performances prévisibles et constantes. C'est très utile dans certains cas. Mais le modèle JIT de JavaScript observe votre code pendant son exécution, puis génère du code machine optimisé, adapté aux données réellement traitées. Avec le temps, le code compilé par JIT peut devenir absurdement rapide.

Prenez la spécialisation de forme des objets. Si vous traitez un tableau d'objets qui ont tous les mêmes propriétés dans le même ordre, V8 détecte ce schéma monomorphe et génère du code machine qui accède aux propriétés via un décalage mémoire fixe, sans recherche dans une table de hachage ni vérification de type. C'est en gros les performances d'une struct C, dans du JavaScript dynamique.

// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number;   // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.

WASM ne peut pas faire cela. Ses caractéristiques de performance sont figées à la compilation. Pour des boucles numériques serrées et prévisibles, ça suffit : le compilateur Rust a déjà optimisé le code à fond. Mais pour le code désordonné, polymorphe et riche en callbacks qui caractérise la plupart des applications web, la capacité du JIT à se spécialiser à l'exécution est un avantage réel.

Taille du bundle et démarrage à froid : les métriques que vous oubliez

Le débit n'est pas la seule métrique de performance. Les utilisateurs ressentent le temps de chargement, la latence d'interaction et le délai avant le premier résultat. WASM a un coût réel sur ces trois plans.

  • Un module Rust WASM avec wasm-bindgen pèse généralement entre 100 Ko et 1,5 Mo compressé en gzip. L'équivalent TypeScript, minifié puis compressé, fait en général entre 10 et 40 Ko.
  • Les modules WASM doivent être compilés en code natif avant d'être exécutés. La compilation en flux aide, mais c'est tout de même un travail que le navigateur doit effectuer.
  • Les moteurs JavaScript parsent de manière paresseuse : les fonctions ne sont compilées qu'au moment de leur premier appel. WASM paie le coût total de la compilation dès le départ.
  • Le cache de code de V8 signifie que les visites répétées évitent entièrement le parsing JS. La mise en cache de la compilation WASM existe, mais elle est moins mature.

Voici une comparaison réelle issue de notre projet. Le bundle du parseur WASM faisait 380 Ko compressé en gzip, contre 26 Ko pour le remplacement TypeScript. Sur une bonne connexion, peu importe. Sur une 3G bridée (que je simule pour chaque revue de performance), la version WASM ajoutait 1,8 seconde de temps de chargement. Ce n'est pas une erreur d'arrondi : c'est la différence entre un utilisateur qui reste et un utilisateur qui quitte la page.

Le démarrage à froid comptait aussi. Le module WASM a mis environ 110 ms à se compiler et à s'instancier sur un téléphone de milieu de gamme. La version TypeScript était interactive en moins de 20 ms. Pour un outil que les utilisateurs ouvrent des dizaines de fois par jour, ça finit par devenir une vraie frustration.

Quand WASM gagne vraiment (et quand vous devriez l'utiliser)

Je ne veux pas laisser penser que WASM est une mauvaise technologie. C'en est une excellente, avec un créneau bien précis. Le problème, c'est que les gens l'utilisent en dehors de ce créneau, puis s'étonnent que les choses ralentissent.

WASM gagne quand le calcul est lourd, que les franchissements de frontière sont peu nombreux et que les données sont numériques. Pensez aux filtres d'image appliqués à des buffers de pixels, au hachage cryptographique, aux simulations physiques, au DSP audio ou aux codecs vidéo. Vous poussez un gros bloc de données dans la mémoire linéaire, vous laissez WASM le traiter dans une boucle serrée, puis vous récupérez le résultat. Un franchissement à l'entrée, un à la sortie, énormément de calcul entre les deux. C'est là que WASM brille.

WASM gagne aussi lorsque vous avez besoin de performances déterministes. La compilation JIT est puissante mais imprévisible : les premiers appels d'une fonction JS s'exécutent en mode interprété, puis en compilation de base, et éventuellement en compilation optimisée. Si vous avez besoin d'une latence constante dès la toute première invocation (boucles de jeu, audio temps réel, calculs financiers), la compilation AOT de WASM vous l'offre.

Et WASM est évidemment le bon choix quand vous portez une base de code native existante. Personne ne devrait réécrire SQLite en JavaScript pour éviter le surcoût de WASM. Ce serait absurde. Le code C existant représente des décennies d'optimisation que vous ne reproduirez jamais.

Un cadre de décision pratique : WASM ou TypeScript ?

Après cet exercice sur trois projets, je me suis retrouvé avec une checklist simple. Elle n'est pas parfaite, mais elle m'a évité deux mauvais choix jusqu'ici.

  1. Comptez les franchissements de frontière. Si votre module WASM appelle JS (ou l'inverse) plus de quelques centaines de fois par opération, TypeScript gagnera probablement. Profilez pour en être sûr.
  2. Regardez vos types de données. Surtout des nombres et des typed arrays ? WASM est un bon choix. Surtout des chaînes, des objets et des callbacks ? Restez en JS.
  3. Mesurez le coût de démarrage. Si le code s'exécute au chargement de la page ou en réponse à des clics utilisateur, la surcharge de compilation de WASM compte. S'il tourne dans un worker de longue durée, le démarrage s'amortit.
  4. Vérifiez votre budget de bundle. Si la taille du bundle vous pose déjà problème, ajouter un module WASM de 400 Ko va faire mal.
  5. Pensez au coût pour les développeurs. WASM ajoute un système de build Rust ou C++, la complexité des source maps et un débogage plus difficile. Si le gain de performance est marginal, la complexité n'en vaut pas la peine.
  6. Benchmarkez avec des données réelles. Les microbenchmarks mentent. Les schémas de surcoût n'apparaissent qu'avec des entrées de taille production et des schémas d'appels réalistes.

Ce que j'ai appris de la réécriture : avant et après

Voici les chiffres réels de notre migration du parseur. Ce ne sont pas des benchmarks synthétiques : ils proviennent du traitement de notre corpus de 200 documents, de 50 à 2 000 lignes chacun.

Metric                  | Rust/WASM    | TypeScript   | Delta
------------------------|--------------|--------------|--------
Median parse time       | 23.1ms       | 14.4ms       | -38%
P99 parse time          | 58.3ms       | 30.7ms       | -47%
Bundle size (gzip)      | 380KB        | 26KB         | -93%
Cold start              | 142ms        | 18ms         | -87%
Boundary crossings/doc  | ~12,000      | 0            | -100%
String serialization    | 31% of time  | 0%           | eliminated
Debug/profile ease      | painful      | native       | huge win

La réécriture en TypeScript n'était pas un portage direct. J'ai repensé la représentation de l'AST pour qu'elle s'entende bien avec l'optimiseur de V8 : formes d'objets monomorphes, énumérations numériques au lieu de tags en chaînes, stockage en tableau plat au lieu d'arbres gourmands en pointeurs. Ce sont des optimisations auxquelles vous ne penseriez jamais en Rust, car le compilateur Rust gère ce niveau de détail. En JavaScript, il faut aller à mi-chemin du JIT.

// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number;       // numeric, not string — cheaper comparisons
start: number;      // byte offset
end: number;
parent: number;     // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number;      // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}

Le point clé ? Le code le plus rapide n'est pas celui écrit dans le langage le plus rapide. C'est celui qui travaille avec son runtime au lieu de lutter contre lui. Notre code Rust était un excellent Rust. Mais une fois compilé en WASM et intégré dans une application JavaScript, il se battait contre la plateforme à chaque instant.

Arrêtez de supposer, commencez à mesurer

J'utilise toujours WASM. J'ai un pipeline de traitement d'images basé sur WASM qui est 4 fois plus rapide que tout ce que je pourrais écrire en JavaScript, parce qu'il opère sur des buffers de pixels bruts sans aucun franchissement de frontière. Le bon outil pour le bon travail.

Mais j'ai arrêté de choisir WASM par défaut quand j'ai besoin de performances. Ma nouvelle habitude : écrire d'abord la version TypeScript, la mesurer avec des données réelles, et ne recourir à WASM que lorsque le profileur me dit que c'est nécessaire, et seulement pour le chemin critique réellement lent. Pas tout le module. Pas toute la fonctionnalité. Seulement la boucle de calcul serrée qui profite vraiment de la compilation AOT.

Écrivez-le en TypeScript. Mesurez. Si c'est assez rapide, livrez. Sinon, déplacez la boucle critique, et uniquement elle, vers WASM. Vous finirez avec moins de code, des bundles plus légers et des applications plus rapides.

La plateforme web vous offre deux modèles d'exécution puissants. Utilisez-les tous les deux, mais là où ils vous aident réellement, pas là où votre intuition vous dit qu'ils devraient le faire.