D-OPEN

J’ai porté un parseur Rust dans le navigateur en WebAssembly — 7 étapes, 340 Ko de binaire et l’erreur qui m’a coûté 2 jours

Écran de développement affichant du code compilé pour le web
Sophie Mercier

Sophie Mercier

Développeuse systèmes · 20 août 2026 · 15 min de lecture

TL;DR — L’essentiel en 30 secondes

  • 7 étapes reproductibles pour porter une bibliothèque Rust vers le navigateur : vérifier la portabilité, installer la chaîne, dessiner la frontière, annoter, réduire la taille, maîtriser la mémoire, tester et publier.
  • • Le binaire est passé de 1,9 Mo à 340 Ko avant compression, sans toucher à l’algorithme — uniquement en simplifiant la frontière et en retirant le formatage des messages d’erreur.
  • L’erreur qui m’a coûté deux jours : exposer une fonction appelée dans une boucle. Le coût de franchissement de la frontière avait mangé tout le gain, et la version WebAssembly était plus lente que le JavaScript d’origine.
  • • Durée réaliste : 2 à 3 jours pour un premier portage d’une bibliothèque de calcul déjà écrite et testée.

Le point de départ était banal : un parseur de fichiers de description industrielle, écrit en Rust, utilisé jusque-là en ligne de commande. Le besoin était de le faire tourner dans le navigateur, pour qu’un utilisateur puisse déposer un fichier et voir le résultat sans que rien ne parte sur un serveur.

Sur le papier, WebAssembly est fait exactement pour ça. En pratique, le premier portage a été plus lent que la version JavaScript qu’il devait remplacer, et le binaire pesait presque deux mégaoctets. Les deux problèmes avaient la même cause, et cette cause n’est presque jamais mentionnée dans les tutoriels d’introduction.

Voici la méthode dans l’ordre où je l’applique maintenant. Comptez deux à trois jours pour une bibliothèque de calcul déjà écrite et testée. Elle vaut pour n’importe quelle bibliothèque Rust, pas seulement un parseur.

Étape 1 — Vérifier que la bibliothèque est portable, avant tout le reste

La cible navigateur, wasm32-unknown-unknown, n’a ni système de fichiers, ni horloge système fiable, ni sockets, ni fils d’exécution par défaut. Toute dépendance qui touche à l’un de ces domaines fera échouer la compilation, souvent avec un message qui pointe trois niveaux plus bas dans l’arbre de dépendances.

Faites l’inventaire avant d’écrire une ligne. Listez l’arbre complet et cherchez les suspects habituels : accès disque, génération de nombres aléatoires reposant sur le système, mesure du temps, journalisation avec écriture de fichier. Sur mon parseur, deux dépendances posaient problème, et une seule était nécessaire — l’autre servait à une fonctionnalité de ligne de commande qui n’avait aucun sens dans le navigateur.

La bonne réponse est presque toujours de découper la bibliothèque en deux : un cœur de calcul sans dépendance système, et une enveloppe qui gère les entrées-sorties. C’est un travail de conception utile même si vous abandonnez WebAssembly ensuite.

Étape 2 — Installer la chaîne de compilation

Trois commandes suffisent, et il n’y a pas de piège si vous les passez dans cet ordre :

rustup target add wasm32-unknown-unknown
cargo install wasm-pack
cargo install wasm-opt   # optimiseur de binaire, utilisé à l’étape 5

Déclarez ensuite le type de bibliothèque dans votre manifeste. C’est l’oubli qui produit le message d’erreur le plus déroutant du portage, parce qu’il ne parle ni de WebAssembly ni du navigateur :

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"

Gardez rlib à côté de cdylib : sans lui, vos tests unitaires natifs cessent de compiler, et vous vous retrouvez à choisir entre tester et publier.

Étape 3 — Dessiner la frontière avant d’écrire la moindre annotation

C’est l’étape que j’ai sautée et qui m’a coûté deux jours. Prenez une feuille et écrivez la liste des fonctions que le JavaScript appellera réellement. Pas les fonctions publiques de votre bibliothèque : celles que la page web appellera.

Sur mon parseur, la liste initiale comptait onze fonctions, dont une parse_line que le JavaScript appelait dans une boucle sur chaque ligne du fichier. C’était l’erreur. Chaque franchissement de frontière implique une conversion de représentation, et un fichier de 40 000 lignes produisait 40 000 franchissements. Le coût cumulé dépassait largement le gain de calcul.

La règle qui résume tout : WebAssembly gagne quand un appel fait beaucoup de travail, il perd quand beaucoup d’appels font peu de travail. Une frontière bien dessinée compte trois à cinq fonctions, pas onze.

La correction a consisté à exposer une seule fonction, parse_document, qui reçoit le contenu entier et rend le résultat structuré. Onze fonctions sont devenues trois. Le temps de traitement est passé de 2,4 secondes à 180 millisecondes sur le même fichier, sans que l’algorithme change d’une ligne.

Coût du franchissement de frontière — même algorithme, deux découpages

FICHIER DE 40 000 LIGNES — TEMPS TOTALFrontière fine — 40 000 appels à parse_line()2 400 ms — dont ~2 050 ms de conversion à la frontièreFrontière large — 1 appel à parse_document()180 msFacteur 13, sans une seule ligne d’algorithme modifiéeLa version « fine » était plus lente que le JavaScript qu’elle devait remplacer.

Étape 4 — Annoter avec wasm-bindgen et traiter les types composés

Les types simples traversent la frontière sans effort : entiers, flottants, booléens, chaînes. Tout le reste demande une décision explicite.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn parse_document(contenu: &str) -> Result<JsValue, JsError> {
    let doc = crate::core::parse(contenu)
        .map_err(|e| JsError::new(&e.to_string()))?;
    Ok(serde_wasm_bindgen::to_value(&doc)?)
}

Deux points méritent l’attention. D’abord, Result avec JsError : les erreurs Rust deviennent alors de vraies exceptions JavaScript, attrapables dans un try. Sans cela, une panique Rust interrompt le module entier et le laisse dans un état inutilisable — il faut recharger la page.

Ensuite, la sérialisation des structures. C’est pratique et c’est un coût réel : chaque champ est converti. Si votre résultat est volumineux, préférez rendre un identifiant opaque et exposer des accesseurs, plutôt que de matérialiser tout l’objet côté JavaScript à chaque appel.

Étape 5 — Mesurer, puis réduire la taille du binaire

Un binaire non optimisé est toujours énorme, et l’écart entre le premier essai et le résultat final est spectaculaire pour un effort faible. Compilez d’abord, mesurez ensuite, optimisez enfin — dans cet ordre, sinon vous ne saurez pas ce qui a payé.

# 1,9 Mo — compilation de développement
wasm-pack build --target web

# 620 Ko — mode release
wasm-pack build --release --target web

# 340 Ko — après optimisation pour la taille

Ajoutez ce profil dans votre manifeste, qui fait l’essentiel du travail :

[profile.release]
opt-level = "z"      # optimiser pour la taille
lto = true           # optimisation à l’édition de liens
codegen-units = 1
panic = "abort"      # retire le déroulement de pile
strip = true

La ligne panic = "abort" mérite une mise en garde : elle retire le formatage des messages de panique, ce qui allège fortement le binaire mais rend le diagnostic plus pauvre. Gardez une variante de compilation sans cette option pour vos environnements de test.

Taille du binaire à chaque étape d’optimisation — parseur réel

TAILLE DU MODULE WEBASSEMBLYCompilation de développement1900 KoMode release620 Koopt-level = z + LTO + strip410 Koaprès wasm-opt340 Koservi en Brotli96 Ko96 Ko sur le réseau : à ce niveau, plus personne ne discute le choix de WebAssembly.

Un portage WebAssembly bloqué sur la frontière ou la taille du binaire ?

Nos développeurs systèmes reprennent le portage, mesurent le gain réel et livrent un paquet publiable.

Lance-toi

Étape 6 — Maîtriser la mémoire et les copies à la frontière

WebAssembly possède sa propre mémoire linéaire, distincte du tas JavaScript. Tout ce qui traverse est copié, sauf si vous vous y prenez autrement. Pour un fichier de quelques mégaoctets envoyé puis rendu, cela fait deux copies complètes que personne n’a demandées.

Deux réflexes suffisent dans la grande majorité des cas. Premièrement, passez les données binaires en &[u8] plutôt qu’en chaîne : l’outillage fait alors une copie directe sans passer par un décodage UTF-8. Deuxièmement, pour les gros résultats, exposez une structure conservée côté Rust et rendez au JavaScript une vue sur la mémoire du module plutôt qu’une copie.

Attention à un piège de durée de vie : une vue mémoire devient invalide si le module réalloue sa mémoire entre-temps. Lisez-la immédiatement, ou copiez-la explicitement si vous devez la conserver. Ce bogue ne se manifeste que sur les gros fichiers, donc jamais pendant vos tests, et toujours chez un utilisateur.

Enfin, les objets exportés par l’outillage ne sont pas libérés par le ramasse-miettes JavaScript. Appelez explicitement leur méthode de libération quand vous avez fini, sous peine d’une fuite mémoire silencieuse dans une application à page unique.

Étape 7 — Tester dans un vrai navigateur, puis publier

Vos tests natifs ne prouvent rien sur la cible WebAssembly. Les différences de représentation des flottants, de gestion des paniques et de comportement mémoire produisent des écarts réels. Exécutez la suite dans un navigateur sans interface :

# dans les fichiers de test
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);

# exécution
wasm-pack test --headless --firefox
wasm-pack test --headless --chrome

Testez sur au moins deux moteurs. Sur ce portage, un test passait sur l’un et échouait sur l’autre à cause d’un arrondi de flottant en bordure de tolérance — le genre d’écart qu’on met une journée à comprendre si on ne le découvre qu’en production.

Pour la publication, l’outillage produit un dossier prêt à publier, contenant le module, l’enveloppe JavaScript et les définitions de types TypeScript. Ne committez jamais le binaire compilé dans le dépôt : il produit des différences illisibles à chaque compilation et pollue l’historique. Publiez-le comme artefact depuis votre intégration continue.

Les 3 erreurs que j’ai commises, pour que vous les évitiez

1. Avoir dessiné la frontière en dernier. C’est l’erreur qui coûte deux jours et qui donne l’impression que WebAssembly ne tient pas ses promesses. Dessinez-la en premier, sur papier, avant la première annotation.

2. Avoir optimisé la taille avant d’avoir mesuré la performance. J’ai passé une demi-journée à gagner 40 Ko sur un module qui était fonctionnellement mal découpé. L’ordre correct est : ça marche, puis c’est rapide, puis c’est petit.

3. Avoir négligé l’origine du fichier traité. Un module qui analyse un fichier déposé par l’utilisateur traite une entrée non fiable — un parseur reste un parseur, et les mêmes précautions s’appliquent que côté serveur. Nos confrères de WebGuard Agency publient régulièrement sur le traitement des entrées non fiables. Et si votre besoin est plutôt d’industrialiser le déploiement du module dans une application métier, Plug-Tech couvre bien le sujet.

Pour aller plus loin côté outillage Rust, voir notre guide sur la création d’une interface en ligne de commande en Rust avec clap, et si le projet est une application web complète, notre page comment créer une marketplace détaille l’architecture d’ensemble.

Questions fréquentes

WebAssembly est-il vraiment plus rapide que JavaScript ?

Pas systématiquement. Sur du calcul dense et long — analyse syntaxique, traitement d’image, compression, géométrie — l’écart est réel, couramment entre deux et dix fois. Sur des appels courts et nombreux, le coût de franchissement annule le gain et le portage peut être plus lent que le JavaScript d’origine. La règle : WebAssembly gagne quand un appel fait beaucoup de travail, il perd quand beaucoup d’appels font peu de travail.

Quelle taille de binaire est acceptable en production ?

Sous 100 Ko compressés, personne ne le remarque. Entre 100 et 500 Ko compressés, c’est acceptable avec un chargement différé après le premier rendu. Au-delà d’un mégaoctet compressé, il faut une justification explicite et un chargement conditionnel. Ici, le binaire est passé de 1,9 Mo à 340 Ko avant compression, essentiellement en simplifiant la frontière et en retirant le formatage des messages d’erreur.

Peut-on utiliser des fils d’exécution Rust dans le navigateur ?

C’est possible mais rarement souhaitable pour un premier portage. Le parallélisme repose sur la mémoire partagée et les web workers, ce qui impose des en-têtes d’isolation d’origine côté serveur. Beaucoup d’hébergements statiques ne les envoient pas, et leur absence provoque un échec silencieux à l’instanciation. Restez mono-fil et déportez l’appel dans un web worker classique si l’objectif est de ne pas bloquer l’interface.

Faut-il publier le paquet sur npm ou le servir soi-même ?

Publiez sur npm si la bibliothèque a vocation à être réutilisée : l’outillage génère le paquet et les définitions TypeScript, ce qui rend l’intégration triviale. Servez-le vous-même si le module n’a de sens que dans votre application, pour contrôler le cache et le chargement différé. Dans les deux cas, versionnez le binaire dans les artefacts de publication, jamais dans le dépôt de code.

Le gain de WebAssembly se décide à la frontière, pas dans l’algorithme.

Nous reprenons votre portage, mesurons le gain réel dans un vrai navigateur et livrons un paquet publiable et testé.

Lance-toi