Articoli approfonditi sulla tecnologia che plasma il futuro.

Quando WebAssembly non è più veloce di JavaScript

WebAssembly non è sempre più veloce di JavaScript. Benchmark reali mostrano quando TypeScript batte WASM e perché il passaggio di confine penalizza le prestazioni.

Corridore bloccato a un posto di frontiera mentre un altro scatta libero oltre

Il mese scorso ho passato un sabato a riscrivere in TypeScript un parser di metadati di immagini che era in Rust compilato in WASM. La versione WASM era in produzione da otto mesi. Funzionava bene. Nessuno si lamentava. Però stavo fissando i nostri trace di performance e qualcosa mi dava fastidio: il parser WASM passava più tempo a spostare dati attraverso il confine JS-WASM che a fare il parsing vero e proprio. Così l'ho riscritto. TypeScript puro, niente WASM. Il risultato è stato il 38% più veloce per il nostro payload mediano. Non ho scritto algoritmi migliori. Ho solo smesso di pagare la tassa del passaggio di confine.

Quell'esperienza mi ha fatto scattare qualcosa in testa. Ero uno di quegli sviluppatori che davano per scontato che WebAssembly fosse l'opzione veloce, punto e basta. A quanto pare, quel presupposto è sbagliato più spesso di quanto la maggior parte di noi pensi.

Il mito del «WASM è sempre più veloce» deve morire

Ecco il modello mentale che la maggior parte degli sviluppatori si porta dietro: il linguaggio compilato va in WASM, WASM gira a velocità quasi nativa, quindi WASM è più veloce di JavaScript. Ogni passaggio della catena è più o meno vero. Ma la conclusione non regge, perché ignora il costo di tutto ciò che sta intorno al calcolo: far entrare i dati, far uscire i risultati e l'overhead di far girare due runtime fianco a fianco.

I motori JavaScript sono incredibilmente bravi. V8, SpiderMonkey e JavaScriptCore rappresentano decenni di lavoro di ottimizzazione, finanziato da alcune delle aziende più ricche del pianeta. Fanno compilazione JIT speculativa, inline caching, escape analysis e transizioni di hidden class. Per molti carichi di lavoro, specialmente i pattern ricchi di stringhe, oggetti e callback tipici delle web app, un motore JS moderno genera codice macchina sorprendentemente vicino a quello di un compilatore statico.

WASM non beneficia di queste ottimizzazioni. È compilato in AOT: quello che distribuisci è quello che viene eseguito. È una funzionalità per la prevedibilità, ma significa che WASM non può adattarsi ai pattern di runtime come fa un JIT.

Il problema del passaggio di confine: morte da mille chiamate

Questo è il punto cruciale. Ogni chiamata da JavaScript verso WASM (o viceversa) ha un costo. Il motore deve fare il marshalling dei dati, validare i tipi, cambiare contesto di esecuzione. Una singola chiamata costa poco, qualche microsecondo. Ma i carichi che attraversano il confine migliaia di volte per ogni operazione vengono massacrati da questo overhead.

Vedo questo schema continuamente: un team scrive un parser, un transformer o un validator in Rust, lo compila in WASM e poi lo avvolge in codice di glue JavaScript che chiama WASM per ogni nodo, ogni token, ogni passo. Il core WASM potrebbe essere velocissimo isolato, ma il codice di glue lo trasforma in un'autostrada a pedaggio.

// 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.

Ho fatto un benchmark di questo scenario esatto su una codebase reale. La versione WASM elaborava un batch di 100 file di configurazione in 340ms. Il porting in TypeScript ci impiegava 195ms. Non perché TypeScript sia un linguaggio più veloce, non lo è, ma perché il lavoro non lasciava mai un unico contesto di esecuzione.

Perché le stringhe distruggono le prestazioni di WASM

WASM opera su una memoria lineare, un buffer piatto di byte. Le stringhe JavaScript sono tutt'altra bestia. Sono oggetti gestiti dal motore, memorizzati in formati interni specializzati (Latin1, UTF-16, rope, cons string). Spostare una stringa da JS a WASM significa codificarla in UTF-8, allocare spazio nella memoria lineare e copiare i byte. Riportarla indietro significa copiare di nuovo e decodificare.

Per il codice che fa calcoli numerici questo non conta. Passi un TypedArray, ricevi numeri indietro. Ma per tutto ciò che tocca molto le stringhe (parser, template engine, validator, serializer) paghi la tassa codifica-copia-elabora-copia-decodifica su ogni frammento di stringa.

// 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.

Ho profilato il nostro parser WASM e ho scoperto che il 31% del tempo di esecuzione totale era serializzazione di stringhe. Non parsing. Non costruzione dell'albero. Solo spostare stringhe avanti e indietro attraverso il confine. La versione TypeScript ha eliminato interamente questa categoria di lavoro.

Se il tuo hot path è pesante di stringhe, probabilmente WASM lo sta rallentando, non accelerando. L'overhead di serializzazione è reale e si accumula in fretta.

JIT: il vantaggio di prestazioni di cui nessuno parla

Il modello AOT di WASM ti garantisce prestazioni prevedibili e costanti. È ottimo per alcuni casi d'uso. Ma il modello JIT di JavaScript fa sì che V8 osservi il codice mentre gira e generi codice macchina ottimizzato sui dati reali che lo attraversano. Col tempo, il codice compilato dal JIT può diventare incredibilmente veloce.

Prendi la specializzazione della forma degli oggetti. Se elabori un array di oggetti che hanno le stesse proprietà nello stesso ordine, V8 riconosce quel pattern monomorfico e genera codice macchina che accede alle proprietà tramite offset di memoria fissi: niente lookup in hash table, niente controlli di tipo. È sostanzialmente prestazioni da struct C ottenute da JavaScript dinamico.

// 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 non può farlo. Le sue caratteristiche di prestazione sono fissate in fase di compilazione. Per cicli numerici stretti e prevedibili va benissimo: il compilatore Rust ha già ottimizzato tutto. Ma per il codice disordinato, polimorfico e ricco di callback che caratterizza la maggior parte delle web app, la capacità del JIT di specializzarsi a runtime è un vantaggio concreto.

Dimensione del bundle e cold start: le metriche che dimentichi

Il throughput non è l'unica metrica di performance. Gli utenti sperimentano tempo di caricamento, latenza delle interazioni e tempo al primo risultato. WASM ha costi reali in tutti e tre.

  • Un modulo Rust con wasm-bindgen tipicamente pesa 100KB-1,5MB gzippato. L'equivalente in TypeScript, minificato e gzippato, di solito è 10-40KB.
  • I moduli WASM devono essere compilati in codice nativo prima di essere eseguiti. La compilazione in streaming aiuta, ma resta lavoro che il browser deve fare.
  • I motori JavaScript fanno il parsing in modo pigro: le funzioni non vengono compilate finché non vengono chiamate. WASM paga subito l'intero costo di compilazione.
  • La code cache di V8 fa sì che le visite successive saltino del tutto il parsing di JS. La cache di compilazione di WASM esiste ma è meno matura.

Ecco un confronto reale dal nostro progetto. Il bundle del parser WASM era 380KB gzippato. Il sostituto in TypeScript era 26KB. Con una buona connessione, poco male. Su una connessione 3G limitata (che simulo in ogni review di performance), la versione WASM aggiungeva 1,8 secondi di tempo di caricamento. Non è un errore di arrotondamento: è la differenza tra un utente che resta e uno che se ne va.

Anche il cold start contava. Il modulo WASM impiegava circa 110ms per compilarsi e istanziarsi su un telefono di fascia media. La versione TypeScript era interattiva in meno di 20ms. Per uno strumento a cui gli utenti ricorrono decine di volte al giorno, la frustrazione si accumula.

Quando WASM vince davvero (e dovresti usarlo)

Non voglio dare l'impressione che WASM sia una cattiva tecnologia. È un'ottima tecnologia con un punto di forza specifico. Il problema è che la gente lo usa al di fuori di quel punto di forza e poi si chiede perché le cose siano peggiorate.

WASM vince quando il calcolo è pesante, gli attraversamenti del confine sono pochi e i dati sono numerici. Pensa a filtri per immagini che operano su buffer di pixel, hashing crittografico, simulazioni fisiche, DSP audio, codec video. Spingi un grosso blocco di dati nella memoria lineare, lasci che WASM lo elabori in un ciclo stretto e riporti indietro il risultato. Un attraversamento in entrata, uno in uscita, tanto calcolo in mezzo. È lì che WASM brilla.

WASM vince anche quando ti servono prestazioni deterministiche. La compilazione JIT è potente ma imprevedibile: le prime chiamate a una funzione JS girano interpretate, poi compilate in baseline, poi magari ottimizzate. Se ti serve una latenza costante fin dalla primissima invocazione (game loop, audio in tempo reale, calcoli finanziari), la compilazione anticipata di WASM te la garantisce.

Ed è ovviamente la scelta giusta quando stai portando una codebase nativa esistente. Nessuno dovrebbe riscrivere SQLite in JavaScript per evitare l'overhead di WASM. Sarebbe folle. Il codice C esistente rappresenta decenni di ottimizzazioni che non potresti mai replicare.

Un framework pratico per decidere: WASM o TypeScript?

Dopo aver fatto questo esercizio su tre progetti, sono arrivato a una checklist semplice. Non è perfetta, ma mi ha già evitato due scelte sbagliate.

  1. Conta gli attraversamenti del confine. Se il tuo modulo WASM chiama JS (o viceversa) più di qualche centinaio di volte per operazione, probabilmente vincerà TypeScript. Fai il profiling per esserne sicuro.
  2. Guarda i tipi di dato. Principalmente numeri e typed array? WASM è una buona scelta. Principalmente stringhe, oggetti e callback? Resta in JS.
  3. Misura il costo di avvio. Se il codice gira al caricamento della pagina o in risposta ai click dell'utente, l'overhead di compilazione di WASM conta. Se gira in un worker di lunga durata, l'avvio si ammortizza.
  4. Controlla il budget del bundle. Se hai già problemi con la dimensione del bundle, aggiungere un modulo WASM da 400KB ti farà male.
  5. Considera il costo in termini di DX. WASM aggiunge un build system Rust o C++, complessità delle source map e debugging più difficile. Se il guadagno di prestazioni è marginale, la complessità non vale la pena.
  6. Fai benchmark con dati reali. I microbenchmark mentono. I pattern di overhead emergono solo con input di dimensioni di produzione e pattern di chiamata realistici.

Cosa ho imparato dalla riscrittura: prima e dopo

Ecco i numeri reali della migrazione del nostro parser. Non sono benchmark sintetici: vengono dall'elaborazione del nostro corpus reale di 200 file, da 50 a 2.000 righe.

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 riscrittura in TypeScript non è stata un porting diretto. Ho riprogettato la rappresentazione dell'AST per funzionare bene con l'ottimizzatore di V8: forme di oggetti monomorfiche, enum numerici al posto dei tag stringa, storage in array piatti invece di alberi pieni di puntatori. Sono ottimizzazioni a cui in Rust non penseresti mai, perché il compilatore Rust gestisce quel livello di dettaglio. In JavaScript devi andare incontro al JIT a metà strada.

// 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;
}

La conclusione chiave? Il codice più veloce non è quello scritto nel linguaggio più veloce. È quello che lavora con il proprio runtime invece che contro di esso. Il nostro codice Rust era un ottimo Rust. Ma una volta compilato in WASM e incorporato in un'applicazione JavaScript, combatteva con la piattaforma a ogni passo.

Smetti di supporre, inizia a misurare

Uso ancora WASM. Ho una pipeline di elaborazione immagini basata su WASM che è 4 volte più veloce di qualsiasi cosa potrei scrivere in JavaScript, perché opera su buffer di pixel grezzi con zero attraversamenti del confine. Lo strumento giusto per il lavoro giusto.

Ma ho smesso di usare WASM come default quando mi servono prestazioni. Il mio nuovo default è scrivere prima la versione TypeScript, misurarla con dati reali e ricorrere a WASM solo quando il profiler mi dice che serve, e solo per lo specifico hot path che è davvero lento. Non l'intero modulo. Non l'intera funzionalità. Solo il ciclo computazionale stretto che beneficia davvero della compilazione anticipata.

Scrivilo in TypeScript. Misuralo. Se è abbastanza veloce, rilascialo. Se non lo è, sposta il ciclo critico, e solo quello, in WASM. Finirai con meno codice, bundle più piccoli e applicazioni più veloci.

La piattaforma web ti offre due modelli di esecuzione potenti. Usali entrambi. Ma usali dove servono davvero, non dove la tua intuizione dice che dovrebbero servire.