Wenn WebAssembly nicht schneller als JavaScript ist
WebAssembly ist nicht immer schneller als JavaScript. Echte Benchmarks zeigen, wann TypeScript gewinnt und warum Grenzübergänge Performance kosten.

Letzten Monat habe ich einen Samstag damit verbracht, einen Rust-zu-WASM-Parser für Bild-Metadaten in TypeScript neu zu schreiben. Die WASM-Version war seit acht Monaten in Produktion. Sie lief einwandfrei, niemand hatte sich beschwert. Aber unsere Performance-Traces ließen mir keine Ruhe: Der WASM-Parser verbrachte mehr Zeit damit, Daten über die JS-WASM-Grenze zu schieben, als mit dem eigentlichen Parsen. Also habe ich ihn neu geschrieben. Reines TypeScript, kein WASM. Das Ergebnis: 38 % schneller bei unserer typischen Payload. Ich habe keine besseren Algorithmen geschrieben. Ich habe nur aufgehört, die Grenzübergangssteuer zu zahlen.
Das hat bei mir etwas im Kopf verändert. Ich gehörte zu den Entwicklern, die einfach davon ausgingen, dass WebAssembly die schnelle Option ist, Punkt. Wie sich zeigt, liegt diese Annahme öfter daneben, als uns die meisten bewusst ist.
Der Mythos „WASM ist immer schneller“ muss sterben
Die meisten Entwickler haben dieses mentale Modell im Kopf: Kompilierte Sprache wird zu WASM, WASM läuft nahezu nativ, also ist WASM schneller als JavaScript. Jeder Schritt in dieser Kette stimmt grob. Aber die Schlussfolgerung geht nicht auf, weil sie alles ignoriert, was um die eigentliche Berechnung herum passiert: Daten reinholen, Ergebnisse rausbringen und der Overhead, zwei Runtimes nebeneinander laufen zu lassen.
JavaScript-Engines sind verrückt gut. V8, SpiderMonkey und JavaScriptCore stecken Jahrzehnte Optimierungsarbeit, finanziert von einigen der reichsten Firmen der Welt. Sie nutzen spekulative JIT-Kompilierung, Inline-Caching, Escape-Analyse und Hidden-Class-Übergänge. Bei vielen Workloads, besonders bei den string-, objekt- und callback-lastigen Mustern typischer Web-Apps, erzeugt eine moderne JS-Engine Maschinencode, der erstaunlich nah an dem liegt, was ein statischer Compiler liefern würde.
WASM bekommt diese Optimierungen nicht. Es wird AOT-kompiliert. Was du ausliefert, wird ausgeführt. Das ist für Vorhersagbarkeit ein Vorteil, bedeutet aber auch, dass WASM sich nicht an Laufzeitmuster anpassen kann wie ein JIT.
Das Grenzübergangsproblem: Tod durch tausend Aufrufe
Das ist der große Brocken. Jeder Aufruf von JavaScript nach WASM (oder zurück) kostet Overhead. Die Engine muss Daten marshallen, Typen prüfen und den Ausführungskontext wechseln. Ein einzelner Übergang ist günstig, ein paar Mikrosekunden. Workloads, die die Grenze tausendfach pro Operation überqueren, werden aber von diesem Overhead zerlegt.
Ich sehe dieses Muster ständig: Ein Team schreibt einen Parser, Transformer oder Validator in Rust, kompiliert ihn nach WASM und umhüllt ihn mit JavaScript-Glue-Code, der für jeden Knoten, jedes Token, jeden Schritt wieder in WASM springt. Der WASM-Kern mag isoliert betrachtet blitzschnell sein, aber der Glue-Code macht daraus eine Mautstraße.
// 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.
Ich habe genau dieses Szenario an einer echten Codebasis gebenchmarkt. Die WASM-Version verarbeitete einen Batch von 100 Konfigurationsdateien in 340 ms. Der TypeScript-Port schaffte es in 195 ms. Nicht weil TypeScript die schnellere Sprache ist, das ist es nicht, sondern weil die Arbeit nie den einen Ausführungskontext verlassen hat.
Warum Strings die WASM-Performance zerstören
WASM arbeitet auf linearem Speicher, einem flachen Byte-Buffer. JavaScript-Strings sind dagegen ein ganz anderes Tier. Es sind von der Engine verwaltete Objekte in speziellen internen Formaten (Latin1, UTF-16, Ropes, Cons-Strings). Einen String von JS nach WASM zu bringen heißt: nach UTF-8 kodieren, Platz im linearen Speicher allokieren und die Bytes kopieren. Ihn zurückzuholen bedeutet erneut kopieren und dekodieren.
Bei zahlenfressendem Code spielt das keine Rolle. Du übergibst ein TypedArray und bekommst Zahlen zurück. Aber bei allem, was stark mit Strings arbeitet, also Parser, Template-Engines, Validatoren und Serializer, zahlst du die Kodieren-Kopieren-Verarbeiten-Kopieren-Dekodieren-Steuer bei jedem einzelnen String-Fragment.
// 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.
Ich habe unseren WASM-Parser profiliert und festgestellt, dass 31 % der gesamten Ausführungszeit auf String-Serialisierung entfielen. Nicht aufs Parsen. Nicht auf den Aufbau des Baums. Nur aufs Hin- und Herschieben von Strings über die Grenze. Die TypeScript-Version hat diese ganze Kategorie von Arbeit eliminiert.
Wenn dein Hot Path stark string-lastig ist, macht WASM ihn wahrscheinlich eher langsamer als schneller. Der Serialisierungs-Overhead ist real und er summiert sich schnell.
JIT-Kompilierung: Der Performance-Vorteil, über den niemand spricht
Das AOT-Modell von WASM bedeutet vorhersagbare, konsistente Performance. Das ist für manche Anwendungsfälle großartig. Aber das JIT-Modell von JavaScript beobachtet deinen Code im Betrieb und erzeugt dann optimierten Maschinencode, der auf die tatsächlich fließenden Daten abgestimmt ist. Mit der Zeit kann der JIT-kompilierte Code geradezu absurd schnell werden.
Nimm zum Beispiel die Spezialisierung auf Objektformen. Wenn du ein Array von Objekten verarbeitest, die alle dieselben Properties in derselben Reihenfolge haben, erkennt V8 dieses monomorphe Muster und erzeugt Maschinencode, der Properties über feste Speicheroffsets anspricht, ohne Hash-Table-Lookup und ohne Typprüfung. Das ist im Grunde C-Struct-Performance aus dynamischem JavaScript.
// 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 kann das nicht. Seine Performance-Eigenschaften sind zur Compile-Zeit festgelegt. Bei engen, vorhersagbaren numerischen Schleifen ist das in Ordnung, der Rust-Compiler hat da bereits ganze Arbeit geleistet. Aber für den unordentlichen, polymorphen, callback-lastigen Code, der die meisten Web-Apps ausmacht, ist die Fähigkeit des JIT, sich zur Laufzeit zu spezialisieren, ein echter Vorteil.
Bundle-Größe und Kaltstart: Die Metriken, die du vergisst
Durchsatz ist nicht die einzige Performance-Metrik. Nutzer erleben Ladezeit, Interaktionslatenz und Zeit bis zum ersten Ergebnis. WASM hat bei allen drei echte Kosten.
- Ein Rust-WASM-Modul mit wasm-bindgen wiegt typischerweise 100 KB bis 1,5 MB gzipped. Das entsprechende TypeScript, minifiziert und gzipped, liegt meist bei 10 bis 40 KB.
- WASM-Module müssen vor der Ausführung in nativen Code kompiliert werden. Streaming-Kompilierung hilft, aber der Browser muss trotzdem die Arbeit erledigen.
- JavaScript-Engines parsen faul, Funktionen werden erst kompiliert, wenn sie aufgerufen werden. WASM zahlt die volle Kompilierungskosten im Voraus.
- Der Code-Cache von V8 sorgt dafür, dass wiederholte Besuche das JS-Parsing komplett überspringen. WASM-Compilation-Caching gibt es zwar auch, ist aber weniger ausgereift.
Hier ein realer Vergleich aus unserem Projekt. Das WASM-Parser-Bundle war 380 KB gzipped, der TypeScript-Ersatz 26 KB. Bei einer guten Verbindung ist das egal. Bei einer gedrosselten 3G-Verbindung (die ich bei jedem Performance-Review simuliere) hat die WASM-Version 1,8 Sekunden zusätzliche Ladezeit verursacht. Das ist kein Rundungsfehler, das ist der Unterschied zwischen einem Nutzer, der bleibt, und einem, der abspringt.
Auch der Kaltstart war wichtig. Das WASM-Modul brauchte auf einem Mittelklasse-Handy etwa 110 ms zum Kompilieren und Instanziieren. Die TypeScript-Version war in unter 20 ms interaktiv. Bei einem Tool, zu dem Nutzer dutzende Male am Tag greifen, summiert sich das zu echtem Frust.
Wann WASM wirklich gewinnt (und du es nutzen solltest)
Ich will nicht den Eindruck erwecken, WASM sei eine schlechte Technologie. Es ist großartig und hat einen klar umrissenen Sweet Spot. Das Problem ist, dass Leute es außerhalb dieses Sweet Spots einsetzen und sich dann wundern, warum alles langsamer geworden ist.
WASM gewinnt, wenn die Berechnung schwer ist, die Grenzübergänge selten sind und die Daten numerisch vorliegen. Denk an Bildfilter auf Pixel-Buffern, kryptografisches Hashing, Physiksimulationen, Audio-DSP oder Video-Codecs. Du schiebst einen großen Datenblock in den linearen Speicher, lässt WASM ihn in einer engen Schleife durchrechnen und holst das Ergebnis zurück. Ein Übergang hinein, ein Übergang hinaus, massenhaft Rechnung dazwischen. Dort glänzt WASM.
WASM gewinnt auch dann, wenn du deterministische Performance brauchst. JIT-Kompilierung ist mächtig, aber unberechenbar: Die ersten Aufrufe einer JS-Funktion laufen interpretiert, dann baseline-kompiliert, vielleicht irgendwann optimierend kompiliert. Wenn du von der allerersten Ausführung an konstante Latenz brauchst, etwa in Game-Loops, Echtzeit-Audio oder Finanzberechnungen, liefert dir die Ahead-of-Time-Kompilierung von WASM genau das.
Und WASM ist offensichtlich die richtige Wahl, wenn du eine bestehende native Codebasis portierst. Niemand sollte SQLite in JavaScript neu schreiben, nur um WASM-Overhead zu vermeiden. Das wäre schlicht verrückt. Im vorhandenen C-Code steckt Jahrzehnte an Optimierung, die du nie nachbauen würdest.
Ein praktisches Entscheidungsframework: WASM oder TypeScript?
Nachdem ich diese Übung in drei Projekten durchgespielt habe, bin ich bei einer simplen Checkliste gelandet. Sie ist nicht perfekt, hat mich aber inzwischen zweimal davor bewahrt, die falsche Entscheidung zu treffen.
- Zähle die Grenzübergänge. Wenn dein WASM-Modul öfter als ein paar hundert Mal pro Operation in JS hinein- oder zurückruft (oder umgekehrt), gewinnt wahrscheinlich TypeScript. Profiliere, um sicherzugehen.
- Schau dir deine Datentypen an. Hauptsächlich Zahlen und Typed Arrays? Dann passt WASM gut. Hauptsächlich Strings, Objekte und Callbacks? Bleib in JS.
- Miss die Startkosten. Läuft der Code beim Seitenaufruf oder als Reaktion auf Klicks, fällt der Kompilierungs-Overhead von WASM ins Gewicht. Läuft er in einem langlebigen Worker, verteilt sich der Startaufwand.
- Prüfe dein Bundle-Budget. Wenn du bei der Bundle-Größe ohnehin kämpfst, tut dir ein zusätzliches 400-KB-WASM-Modul weh.
- Bedenke die DX-Kosten. WASM bringt ein Rust- oder C++-Build-System, komplexere Source Maps und schwierigeres Debugging mit. Ist der Performance-Gewinn marginal, lohnt sich die Komplexität nicht.
- Benchmarke mit echten Daten. Mikrobenchmarks lügen. Die Overhead-Muster zeigen sich erst mit produktionsgroßen Eingaben und realistischen Aufrufmustern.
Was ich aus der Neuschreibung gelernt habe: vorher und nachher
Lass mich die tatsächlichen Zahlen aus unserer Parser-Migration aufschlüsseln. Das sind keine synthetischen Benchmarks, sondern Messungen an unserem echten Dokumentenkorpus mit 200 Dateien zwischen 50 und 2.000 Zeilen.
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
Die TypeScript-Neuschreibung war keine direkte Portierung. Ich habe die AST-Repräsentation so umgebaut, dass sie mit dem Optimierer von V8 harmoniert: monomorphe Objektformen, numerische Enums statt String-Tags, flache Array-Speicherung statt zeigerlastiger Bäume. Diese Optimierungen würdest du in Rust nie in Betracht ziehen, weil der Rust-Compiler dieses Detailniveau selbst übernimmt. In JavaScript musst du dem JIT auf halbem Weg entgegenkommen.
// 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;
}
Die entscheidende Erkenntnis? Der schnellste Code ist nicht der Code in der schnellsten Sprache. Es ist der Code, der mit seiner Laufzeit arbeitet statt gegen sie. Unser Rust-Code war hervorragendes Rust. Aber nach der Kompilierung zu WASM und der Einbettung in eine JavaScript-Anwendung kämpfte er bei jeder Gelegenheit gegen die Plattform.
Nicht mehr annehmen, sondern messen
Ich nutze WASM immer noch. Ich habe eine WASM-basierte Bildverarbeitungspipeline, die 4-mal schneller ist als alles, was ich in JavaScript schreiben könnte, weil sie auf rohen Pixel-Buffern arbeitet und null Grenzübergänge hat. Richtiges Werkzeug, richtiger Einsatz.
Aber ich greife bei Performance-Problemen nicht mehr standardmäßig zu WASM. Mein neuer Standard: Ich schreibe zuerst die TypeScript-Version, messe sie mit echten Daten und greife nur dann zu WASM, wenn der Profiler es verlangt, und auch nur für den konkreten Hot Path, der tatsächlich langsam ist. Nicht das ganze Modul. Nicht das ganze Feature. Nur die enge Rechenschleife, die wirklich von der Ahead-of-Time-Kompilierung profitiert.
Schreib es in TypeScript. Miss es. Wenn es schnell genug ist, liefere es aus. Wenn nicht, verschiebe die heiße Schleife, und nur die heiße Schleife, nach WASM. Am Ende hast du weniger Code, kleinere Bundles und schnellere Anwendungen.
Die Web-Plattform bietet dir zwei mächtige Ausführungsmodelle. Nutze beide. Aber nutze sie dort, wo sie wirklich helfen, und nicht dort, wo dein Bauchgefühl sagt, dass sie helfen sollten.


