Von Rust zu WebAssembly: der Weg in den Browser
Der Build ist in einer halben Stunde eingerichtet. Ob sich der Umbau lohnt, entscheidet sich daran, wie oft Daten die Grenze zwischen JavaScript und WebAssembly überqueren.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der Engpass sitzt selten dort, wo WebAssembly hilft
Der Anlass ist fast immer derselbe: Eine Ansicht ruckelt, ein Export dauert acht Sekunden, ein Filter über zweihunderttausend Zeilen blockiert den Hauptthread. Danach kommt schnell der Vorschlag, den Teil in Rust neu zu schreiben und als WebAssembly einzubinden. Was dabei oft übersprungen wird, ist die Messung: Ein Profil im Browser zeigt in fünf Minuten, ob die Zeit tatsächlich in der Rechnung steckt oder im Neuaufbau des Baums, im Warten auf eine Antwort oder in einem Zustandsspeicher, der bei jeder Änderung alles neu bewertet.
Die zweite Fehlannahme betrifft die Geschwindigkeit selbst. WebAssembly ist nicht automatisch schneller als JavaScript. Die Übersetzer moderner Browser erzeugen für gut geschriebenen Zahlencode sehr guten Maschinencode, und gegen eine eingespielte Schleife über ein typisiertes Feld gewinnt ein WebAssembly-Modul nicht durch bloße Anwesenheit. Was WebAssembly zuverlässig liefert, ist ein vorhersagbarer Verlauf ohne Rückfall auf einen langsameren Pfad und ohne Pausen der Speicherbereinigung, und die Möglichkeit, vorhandenen Rust- oder C-Code unverändert im Browser laufen zu lassen.
Am teuersten ist die dritte Falle, weil sie erst nach der Umstellung auffällt. Wer die Rechnung nach Rust verlagert, aber die Schnittstelle fein zerlegt lässt, also für jedes Element einen Aufruf macht und jedes Mal eine Zeichenkette hinüberreicht, bezahlt für jede Überquerung mit einer Umwandlung und einer Kopie. Am Ende steht dann ein Modul, das mehr Arbeit macht als der ersetzte JavaScript-Code, und die Fehlersuche beginnt an der falschen Stelle.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der Ablauf, Schritt für Schritt
Sieben Stationen, von der Messung bis zur Fehlersuche im Browser. Die Reihenfolge ist nicht beliebig: Wer den Zuschnitt der Grenze erst nach dem ersten Build klärt, baut zweimal.
- 1
Den Kandidaten messen und eingrenzen
Nimm ein Profil in den Entwicklerwerkzeugen des Browsers auf und suche die Funktion, in der die Zeit tatsächlich verbracht wird. Geeignet ist eine Stelle, die viel rechnet, wenig mit dem Dokument spricht und eine klare Ein- und Ausgabe hat, also etwa ein Bildfilter, ein Parser oder eine Simulationsschleife.
Geschafft, wenn: Eine benannte Funktion mit gemessener Laufzeit, an der sich später vergleichen lässt.
- 2
Crate anlegen und Ausgabeform festlegen
cargo new --lib erzeugt das Projekt. In der Cargo.toml setzt du crate-type auf ["cdylib", "rlib"], nimmst wasm-bindgen als Abhängigkeit auf und legst ein Release-Profil mit opt-level, lto und panic gleich mit an. Der rlib-Anteil ist kein Beiwerk, er erlaubt dir, dieselbe Logik in gewöhnlichen Rust-Tests auf dem Rechner zu prüfen.
Geschafft, wenn: cargo test läuft nativ durch, obwohl das Ziel später der Browser ist.
- 3
Die Grenze definieren
Setz #[wasm_bindgen] nur an die wenigen Funktionen und Typen, die von außen erreichbar sein sollen. Halte die Signaturen grob: ein Aufruf mit einem &[u8] oder einem Vec<f32> statt vieler Aufrufe mit Einzelwerten. Für Zugriffe auf Browserschnittstellen brauchst du web-sys, und dort ist jede Schnittstelle ein eigenes Feature, das du in der Cargo.toml einschaltest.
Geschafft, wenn: Die öffentliche Fläche des Moduls passt auf eine Bildschirmseite.
- 4
Mit wasm-pack bauen
wasm-pack build --release --target bundler erzeugt ein Verzeichnis pkg mit dem Modul, der erzeugten JavaScript-Anbindung, den TypeScript-Deklarationen und einer package.json. Das Ziel entscheidet über die Einbindung: bundler für Vite oder webpack, web für einen direkten Import im Browser mit ausdrücklichem init, nodejs für den Serverbetrieb.
Geschafft, wenn: Im Verzeichnis pkg liegen .wasm, .js und .d.ts mit den erwarteten Signaturen.
- 5
In das bestehende Projekt einbinden
Trag das Paket als Abhängigkeit auf den lokalen Pfad ein oder veröffentliche es in eure interne Registry. Beim Ziel bundler übernimmt das Bündelwerkzeug den Rest, beim Ziel web rufst du einmal die exportierte init-Funktion auf und wartest sie ab, bevor du das Modul benutzt. Weil dieser erste Schritt asynchron ist, gehört er in den Ladepfad der Anwendung und nicht mitten in eine Ereignisbehandlung.
Geschafft, wenn: Die Anwendung startet ohne Konsolenfehler und die TypeScript-Typen greifen in der Entwicklungsumgebung.
- 6
Die Modulgröße drücken
wasm-pack ruft wasm-opt aus dem Binaryen-Werkzeugkasten bereits mit auf. Den größeren Hebel hast du im Code: opt-level auf "z" oder "s", lto einschalten, panic auf "abort" setzen und Abhängigkeiten meiden, die Formatierung, reguläre Ausdrücke oder Zeitzonendaten mitziehen. twiggy top zeigt dir, welche Symbole tatsächlich Platz belegen, statt dass du raten musst.
Geschafft, wenn: Die ausgelieferte .wasm-Datei liegt in einer Größenordnung, die du gegenüber dem Frontend-Team vertreten kannst.
- 7
Fehlersuche einrichten
Binde console_error_panic_hook in den Entwicklungsbuilds ein, damit ein Panic als lesbare Meldung samt Aufrufliste in der Konsole landet statt als unspezifischer Fehler. Für Tests im Browser gibt es wasm-bindgen-test, das die Prüfungen in einem kopflosen Chrome oder Firefox ausführt und damit auch Code abdeckt, der Browserschnittstellen anspricht.
Geschafft, wenn: Ein absichtlich ausgelöster Panic zeigt Datei und Zeile aus dem Rust-Code in der Browserkonsole.
Sechs Bausteine vom Rust-Crate zum geladenen Modul
- 01 Ein Bibliotheks-Crate mit crate-type cdylib bildet den Ausgangspunkt.
- 02 wasm-bindgen markiert die wenigen Funktionen, die nach außen sichtbar sind.
- 03 wasm-pack baut daraus Modul, JavaScript-Anbindung und Typdeklarationen.
- 04 wasm-opt schrumpft das Ergebnis, bevor es in das Bündel wandert.
- 05 Das Projekt lädt das Paket wie jede andere npm-Abhängigkeit.
- 06 Der Server liefert die Datei als application/wasm, sonst entfällt der schnelle Start.
Danach schneidest du die Grenze so zu, dass der Gewinn übrig bleibt
Der Werkzeugteil ist gut abgehangen und in wenigen Schritten erledigt. Die Entscheidungen, die den Unterschied machen, betreffen den Zuschnitt: welcher Teil überhaupt hinüberwandert, wie grob die Aufrufe geschnitten sind und wie groß das Modul werden darf, das jeder Besucher lädt.
Vorher messen
Du liest ein Browserprofil und weißt danach, ob die Zeit in der Rechnung liegt oder im Neuaufbau der Oberfläche. Nur im ersten Fall bringt ein Modul überhaupt etwas.
Die Schnittstelle grob schneiden
Du reichst einen großen Puffer einmal hinüber statt zehntausend kleine Werte einzeln, und du weißt, welche Typen dabei kopiert werden und welche nur als Zahl über die Grenze gehen.
Den Build einrichten
Du kennst die Aufgabenteilung zwischen wasm-bindgen und wasm-pack und wählst das passende Ziel, je nachdem ob ein Bündelwerkzeug, ein direkter Import im Browser oder Node das Ergebnis lädt.
Auf die Modulgröße achten
Du erkennst die Abhängigkeiten, die ein kleines Modul auf mehrere hundert Kilobyte aufblähen, und findest mit twiggy die Funktionen, die den Platz belegen.
Fehler sichtbar machen
Du sorgst dafür, dass ein Panic in der Konsole als lesbare Meldung mit Aufrufliste erscheint, statt als unspezifischer Fehler aus dem Glue-Code.
Den Aufwand abwägen
Du kannst begründen, warum Bildverarbeitung, Simulation, Parser und Kryptografie sich eignen und warum eine formularlastige Ansicht in JavaScript schneller fertig ist.
KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Was ein WebAssembly-Modul kann und was nicht
WebAssembly ist ein binäres Befehlsformat, das dieselbe Maschine ausführt, die auch das JavaScript der Seite ausführt. Ein Modul bekommt einen linearen Speicherbereich, ein zusammenhängendes Feld aus Bytes, und mehr sieht es von der Welt zunächst nicht. Es gibt keinen direkten Zugriff auf das Dokument, keine Netzwerkaufrufe und keinen Zugriff auf Speicher außerhalb dieses Bereichs. Jede Berührung mit dem Browser läuft über importierte Funktionen, und genau die erzeugt wasm-bindgen für dich.
Zwei Dinge, die im Serverumfeld selbstverständlich sind, brauchen hier Vorbereitung. Threads sind kein cargo build entfernt: Der Browser stellt SharedArrayBuffer nur bereit, wenn der Server die Seite mit den passenden Isolationskopfzeilen ausliefert, und auf der Rust-Seite kommen die Zielmerkmale atomics und bulk-memory sowie eine eigens übersetzte Standardbibliothek dazu. Rechne das als eigenen Posten ein. Vektorbefehle stehen als SIMD-Erweiterung bereit, müssen aber beim Übersetzen ausdrücklich freigeschaltet werden, und das erzeugte Modul läuft dann nur dort, wo die Erweiterung unterstützt wird.
Nicht Gegenstand dieser Seite, aber der häufigste Anschlussgedanke: Dieselben Module lassen sich außerhalb des Browsers ausführen, mit einer Systemschnittstelle statt der Browserumgebung. Das ist ein eigenes Ziel-Tripel und eine eigene Werkzeugkette, und wer Rust auf dem Server einsetzen will, fährt in aller Regel besser mit einer gewöhnlichen nativen Binärdatei.
wasm-bindgen und wasm-pack, und wer was macht
wasm-bindgen ist zweierlei: eine Crate mit einem Makro und ein Kommandozeilenwerkzeug, das nach dem Übersetzen läuft. Das Makro merkt sich, welche Funktionen und Typen die Grenze überqueren sollen, das Werkzeug erzeugt daraus die JavaScript-Anbindung und die TypeScript-Deklarationen und übernimmt die Umwandlung der Typen in beide Richtungen. Dazu gehören zwei Begleiter: js-sys bildet die eingebauten JavaScript-Typen ab, also Array, Promise, Date und Reflect, und web-sys die Browserschnittstellen von document über fetch bis WebGL.
Bei web-sys stolpern fast alle über dieselbe Stelle. Die Crate deckt praktisch die vollständige Browserschnittstelle ab, und damit euer Modul nicht davon aufgebläht wird, ist jede einzelne Schnittstelle ein Feature. Wer CanvasRenderingContext2d benutzen will, trägt genau dieses Feature in der Cargo.toml ein, sonst meldet der Compiler den Typ als unbekannt. Das wirkt beim ersten Mal umständlich und ist genau der Grund, warum die erzeugten Module klein bleiben.
wasm-pack ist der Dirigent darüber. Ein Aufruf übersetzt für das Ziel wasm32-unknown-unknown, ruft das wasm-bindgen-Werkzeug auf, schickt das Ergebnis durch wasm-opt und schnürt daraus ein npm-Paket mit package.json und Typdeklarationen. Für ein Frontend-Team ist das der entscheidende Teil, denn das Ergebnis lässt sich wie jede andere Abhängigkeit einbinden, ohne dass jemand Rust auf dem Rechner braucht.
An der Grenze verliert man die Geschwindigkeit wieder
Zahlen kosten nichts. Ein f64 oder ein i32 wandert als Wert über die Grenze und ist damit erledigt. Alles andere wird umgeformt. Eine Rust-Zeichenkette liegt als UTF-8 im linearen Speicher, eine JavaScript-Zeichenkette liegt in der Engine und ist anders kodiert, also wird bei jeder Übergabe kopiert und umgewandelt. Ein Vec<u8>, den du zurückgibst, wird in ein neues typisiertes Feld kopiert. Ein struct wird in ein Handle verpackt, hinter dem der eigentliche Wert im Rust-Speicher bleibt. Nichts davon ist teuer bei zehn Aufrufen pro Sekunde, alles davon ist ruinös bei zehntausend.
Die Regel, die daraus folgt: Halte die Zahl der Überquerungen klein und die Datenmenge je Überquerung groß. Statt für jeden Bildpunkt eine Funktion zu rufen, übergibst du den ganzen Puffer einmal und bekommst ihn einmal zurück. Noch besser ist der Weg ganz ohne Kopie: Du legst dir auf JavaScript-Seite eine Sicht auf den linearen Speicher des Moduls an und liest das Ergebnis direkt aus dem Bereich, den Rust beschrieben hat. Für die Bildausgabe auf ein Canvas ist das der übliche Aufbau.
Dabei lauert der Fehler, der die meiste Suchzeit kostet. Wenn das Modul zur Laufzeit mehr Speicher anfordert, wird der zugrunde liegende Puffer ersetzt, und jede JavaScript-Sicht darauf ist danach abgekoppelt und liefert keine Daten mehr. Wer sich die Sicht einmal beim Start merkt und wiederverwendet, bekommt nach der ersten Speichererweiterung leere Bilder oder eine Ausnahme, ohne dass sich am Rust-Code irgendetwas geändert hätte. Die Sicht gehört deshalb nach jeder Anforderung neu geholt, oder der Speicherbedarf wird von vornherein festgelegt.
Ein Modul, das niemand lädt, hat auch nichts beschleunigt
Die Größe entscheidet mit darüber, ob der Umbau am Ende als Verbesserung wahrgenommen wird. Ein reiner Rechenkern ohne Formatierung, ohne Fehlermeldungen und ohne fremde Abhängigkeiten liegt in einer Größenordnung von wenigen zehn Kilobyte. Sobald aber Panic-Meldungen aufbereitet, reguläre Ausdrücke ausgewertet, Zeitzonen aufgelöst oder JSON über eine allgemeine Serialisierung geführt werden, wächst dasselbe Modul schnell um ein Vielfaches. Die Ursache steht selten in eurem Code, sie steht in der Abhängigkeitsliste.
Drei Griffe holen den größten Teil zurück. Im Release-Profil opt-level auf "z" oder "s", lto einschalten und panic auf "abort" setzen, womit die gesamte Abwicklungsmaschinerie entfällt. Danach zeigt twiggy top die Symbole nach Platzbedarf sortiert, und die Liste ist regelmäßig überraschend: Oft stehen ganz oben nicht die Rechenfunktionen, sondern die Aufbereitung von Fehlertexten. Den Panic-Hook, der die Meldungen lesbar macht, willst du deshalb in der Entwicklung, nicht im Auslieferungsbuild.
Der letzte Schritt liegt beim Server und wird oft übersehen. Die Datei muss mit dem Inhaltstyp application/wasm ausgeliefert werden, sonst kann der Browser sie nicht schon beim Herunterladen übersetzen und fällt auf den langsameren Weg zurück. Dazu gehört Kompression über die Leitung, die bei einem Binärformat wie diesem deutlich mehr bringt, als die Zahl im Verzeichnis vermuten lässt.
Wann sich der Aufwand rechnet, und wann nicht
Es lohnt sich, wenn drei Dinge zusammenkommen: eine Rechnung, die spürbar Zeit braucht, eine Schnittstelle, die sich grob schneiden lässt, und ein Ergebnis, das man messen kann. Typische Fälle sind Bild- und Videobearbeitung im Browser, physikalische Simulationen, Parser für eigene oder sperrige Formate, Kompression und Kryptografie. Ein weiterer, oft unterschätzter Fall ist der Umzug von bestehendem Code: Wer eine Bibliothek in Rust oder C bereits hat und sie im Browser braucht, spart sich die Zweitimplementierung und die dauerhafte Pflege zweier Fassungen, die auseinanderlaufen.
Es lohnt sich nicht, wenn die Arbeit im Dokument liegt. Jeder Zugriff auf das Dokument läuft über die Anbindung zurück nach JavaScript, sodass ein Modul, das viele Knoten anfasst, mehr Überquerungen erzeugt, als es Rechenzeit spart. Ebenso wenig lohnt es sich bei Aufgaben, die von Wartezeit auf das Netz bestimmt sind, bei Verbindungslogik zwischen Bibliotheken und bei kleinen Feldoperationen, für die der eingebaute Übersetzer längst guten Code erzeugt.
Und es lohnt sich nicht, wenn im Team niemand Rust kann. Der Build ist eingerichtet, aber die Wartung liegt danach dauerhaft bei den Leuten, die auch das Frontend pflegen. Ein Modul, das ein halbes Jahr nach dem Einbau niemand mehr anfassen mag, ist teurer als die langsamere Fassung in JavaScript.
Dazu passende Kurse
Die Werkzeuge sind schnell eingerichtet, der Zuschnitt der Schnittstelle nicht, und genau daran arbeiten Rust-Kurse rund um WebAssembly .
Weil die Einbindung am Ende im Bündelwerkzeug und im Ladepfad landet, gehören JavaScript-Kurse für das Team im Frontend in dieselbe Planung.
Wie sicher bist du beim Thema wirklich?
Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.
Wo du genau das übst
Sehr angenehmes, gut vorbereitetes Seminar, kann ich nur empfehlen.
Häufige Fragen
Ist WebAssembly schneller als JavaScript?
Kann ich aus Rust direkt auf das Dokument zugreifen?
Wie groß darf so ein Modul werden?
Brauchen die Leute im Frontend danach Rust auf dem Rechner?
Deine Ansprechpartner
Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.
Yves Hoppe
Weiterbildung & Beratung
Hilft dir, aus dem Rust-Programm den passenden Kurs für deinen Stand zu finden.
Norbert Jansen
Beratung & Inhouse
Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.
Erst die Grenze richtig schneiden, dann rechnet sich das Modul
Wie ein Rechenkern aussieht, der im Browser trägt, und woran du die Überquerungen erkennst, arbeitest du bei cmt an einem echten Beispiel durch.