Quando WebAssembly Não É Mais Rápido que JavaScript
WebAssembly nem sempre é mais rápido que JavaScript. Veja benchmarks reais de quando TypeScript vence WASM e por que cruzar a fronteira prejudica tudo.

No mês passado passei um sábado reescrevendo em TypeScript um parser de metadados de imagem que estava em Rust compilado para WASM. A versão em WASM estava em produção havia oito meses. Funcionava bem. Ninguém reclamava. Mas eu vinha olhando nossos traces de performance e uma coisa me incomodava: o parser em WASM gastava mais tempo movendo dados pela fronteira JS-WASM do que de fato fazendo o parsing. Então reescrevi. TypeScript puro, sem WASM. O resultado foi 38% mais rápido para o payload mediano. Não escrevi algoritmos melhores. Só parei de pagar o pedágio da travessia da fronteira.
Essa experiência quebrou alguma coisa na minha cabeça. Eu era do tipo de dev que assumia que WebAssembly era a opção rápida, ponto final. Acontece que essa suposição está errada com mais frequência do que a maioria de nós percebe.
O mito de que “WASM é sempre mais rápido” precisa morrer
Este é o modelo mental que a maioria dos devs carrega: linguagem compilada vai para WASM, WASM roda em velocidade quase nativa, logo WASM é mais rápido que JavaScript. Cada passo dessa cadeia é mais ou menos verdadeiro. Mas a conclusão não se sustenta, porque ignora o custo de tudo em volta da computação: colocar dados dentro, tirar os resultados e o overhead de rodar dois runtimes lado a lado.
Os motores de JavaScript são absurdamente bons. V8, SpiderMonkey e JavaScriptCore representam décadas de trabalho de otimização, financiado por algumas das empresas mais ricas do planeta. Eles fazem compilação JIT especulativa, inline caching, escape analysis e transições de hidden classes. Para muitas cargas de trabalho, especialmente os padrões com muita string, muito objeto e muito callback comuns em aplicações web, um engine moderno de JS gera código de máquina surpreendentemente próximo do que você obteria de um compilador estático.
WASM não recebe essas otimizações. Ele é compilado AOT. O que você envia é o que roda. Isso é um ponto forte em previsibilidade, mas significa que o WASM não consegue se adaptar a padrões de execução da forma como um JIT consegue.
O problema da travessia da fronteira: morte por mil chamadas
Este é o grande ponto. Toda chamada do JavaScript para o WASM (ou de volta) tem overhead. O engine precisa fazer marshaling dos dados, validar tipos e trocar contextos de execução. Uma única travessia é barata, alguns microssegundos. Mas cargas de trabalho que cruzam a fronteira milhares de vezes por operação são massacradas por esse overhead.
Vejo esse padrão o tempo todo: um time escreve um parser, transformador ou validador em Rust, compila para WASM e depois envolve tudo em código de cola JavaScript que chama o WASM para cada nó, cada token, cada passo. O núcleo em WASM pode ser extremamente rápido isoladamente, mas a cola transforma tudo em pedágio.
// 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.
Fiz benchmark exatamente desse cenário em uma base de código real. A versão em WASM processou um lote de 100 arquivos de configuração em 340ms. O port em TypeScript fez isso em 195ms. Não porque TypeScript seja uma linguagem mais rápida, pois não é, mas porque o trabalho nunca saiu de um único contexto de execução.
Por que strings destroem a performance do WASM
O WASM opera sobre memória linear, um buffer plano de bytes. Strings em JavaScript são outra história. Elas são objetos gerenciados pelo engine, armazenados em formatos internos especializados (Latin1, UTF-16, ropes, cons strings). Mover uma string do JS para o WASM significa codificá-la em UTF-8, alocar espaço na memória linear e copiar os bytes. Trazê-la de volta significa copiar de novo e decodificar.
Para código de processamento numérico, isso não importa. Você passa um TypedArray e recebe números de volta. Mas para qualquer coisa que mexa muito com strings, como parsers, template engines, validadores e serializadores, você paga o imposto de codificar-copiar-processar-copiar-decodificar em cada fragmento de string.
// 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.
Fiz profiling do nosso parser em WASM e descobri que 31% do tempo total de execução era serialização de strings. Não parsing. Não construção de árvore. Só mover strings de um lado para o outro pela fronteira. A versão em TypeScript eliminou essa categoria inteira de trabalho.
Se o seu hot path é pesado em strings, provavelmente o WASM está deixando tudo mais lento, não mais rápido. O overhead de serialização é real e se acumula rápido.
Compilação JIT: a vantagem de performance que ninguém menciona
O modelo AOT do WASM garante performance previsível e consistente. Isso é ótimo para alguns casos. Mas o modelo JIT do JavaScript faz o V8 observar seu código rodando e então gerar código de máquina otimizado para os dados reais que passam por ele. Com o tempo, o código compilado pelo JIT pode ficar absurdamente rápido.
Pegue a especialização de shape de objetos. Se você processa um array de objetos que têm as mesmas propriedades na mesma ordem, o V8 detecta esse padrão monomórfico e gera código de máquina que acessa propriedades por offset fixo de memória, sem lookup em tabela hash e sem checagem de tipo. É basicamente performance de struct em C a partir de JavaScript dinâmico.
// 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.
O WASM não consegue fazer isso. Suas características de performance são fixas em tempo de compilação. Para loops numéricos apertados e previsíveis, tudo bem, pois o compilador Rust já otimizou o máximo possível. Mas para o código bagunçado, polimórfico e cheio de callbacks que caracteriza a maioria das aplicações web, a capacidade do JIT de se especializar em tempo de execução é uma vantagem real.
Tamanho do bundle e cold start: as métricas que você está esquecendo
Throughput não é a única métrica de performance. Os usuários sentem tempo de carregamento, latência de interação e tempo até o primeiro resultado. O WASM tem custos reais nos três.
- Um módulo Rust com wasm-bindgen normalmente ocupa 100KB a 1,5MB em gzip. O equivalente em TypeScript, minificado e em gzip, costuma ficar entre 10 e 40KB.
- Módulos WASM precisam ser compilados para código nativo antes de executar. A compilação em streaming ajuda, mas ainda é trabalho que o navegador precisa fazer.
- Engines de JavaScript fazem parsing preguiçoso: funções só são compiladas quando são chamadas. O WASM paga o custo total de compilação logo no início.
- O code cache do V8 faz com que visitas repetidas pulem o parsing do JS por completo. O cache de compilação de WASM existe, mas é menos maduro.
Aqui vai uma comparação real do nosso projeto. O bundle do parser em WASM tinha 380KB em gzip. A substituição em TypeScript tem 26KB. Em uma conexão boa, tanto faz. Numa conexão 3G limitada (que simulo em toda revisão de performance), a versão em WASM adicionou 1,8 segundo ao tempo de carregamento. Isso não é erro de arredondamento: é a diferença entre o usuário ficar ou sair.
O cold start também pesou. O módulo WASM levou cerca de 110ms para compilar e instanciar em um celular intermediário. A versão em TypeScript estava interativa em menos de 20ms. Para uma ferramenta que os usuários acessam dezenas de vezes por dia, isso se transforma em frustração de verdade.
Quando o WASM realmente vence (e quando você deve usá-lo)
Não quero dar a impressão de que WASM é uma tecnologia ruim. É uma ótima tecnologia com um ponto ideal bem específico. O problema é que as pessoas o usam fora desse ponto ideal e depois se perguntam por que as coisas ficaram mais lentas.
O WASM vence quando a computação é pesada, as travessias de fronteira são poucas e os dados são numéricos. Pense em filtros de imagem operando sobre buffers de pixels, hashing criptográfico, simulações físicas, DSP de áudio e codecs de vídeo. Você empurra um bloco grande de dados para a memória linear, deixa o WASM processar num loop apertado e traz o resultado de volta. Uma travessia de ida, uma de volta, muito processamento no meio. É aí que o WASM brilha.
O WASM também vence quando você precisa de performance determinística. A compilação JIT é poderosa, mas imprevisível: as primeiras chamadas de uma função JS rodam interpretadas, depois passam por compilação baseline e talvez por uma compilação otimizada. Se você precisa de latência consistente desde a primeira invocação (game loops, áudio em tempo real, cálculos financeiros), a compilação antecipada do WASM entrega isso.
E o WASM é obviamente a escolha certa quando você está portando uma base de código nativa existente. Ninguém deveria reescrever o SQLite em JavaScript para evitar o overhead do WASM. Isso seria insano. O código C existente representa décadas de otimização que você nunca conseguiria replicar.
Um framework prático de decisão: WASM ou TypeScript?
Depois de passar por esse exercício em três projetos, cheguei a uma checklist simples. Não é perfeita, mas já me salvou de tomar a decisão errada duas vezes.
- Conte as travessias de fronteira. Se o seu módulo WASM chama o JS (ou vice-versa) mais de algumas centenas de vezes por operação, o TypeScript provavelmente vai ganhar. Faça profiling para confirmar.
- Olhe para os tipos de dados. Principalmente números e typed arrays? O WASM é um bom encaixe. Principalmente strings, objetos e callbacks? Fique no JS.
- Meça o custo de inicialização. Se o código roda no carregamento da página ou em resposta a cliques do usuário, o overhead de compilação do WASM importa. Se roda em um worker de longa duração, o custo de startup se dilui.
- Confira o orçamento do bundle. Se você já tem dificuldade com o tamanho do bundle, adicionar um módulo WASM de 400KB vai doer.
- Considere o custo de DX. O WASM adiciona um sistema de build em Rust ou C++, complexidade de source maps e depuração mais difícil. Se o ganho de performance for marginal, a complexidade não vale a pena.
- Faça benchmarks com dados reais. Microbenchmarks mentem. Os padrões de overhead só aparecem com entradas de tamanho de produção e padrões de chamada realistas.
O que aprendi com a reescrita: antes e depois
Deixe-me mostrar os números reais da nossa migração do parser. Não são benchmarks sintéticos: vêm do processamento do nosso corpus real de 200 arquivos, com entre 50 e 2.000 linhas.
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
A reescrita em TypeScript não foi um port direto. Redesenhei a representação da AST para funcionar bem com o otimizador do V8: shapes de objetos monomórficos, enums numéricos em vez de tags de string e armazenamento em array plano em vez de árvores cheias de ponteiros. São otimizações em que você nunca pensaria em Rust, porque o compilador Rust cuida desse nível de detalhe. Em JavaScript, você precisa ir até o JIT no meio do caminho.
// 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;
}
A grande lição? O código mais rápido não é o escrito na linguagem mais rápida. É o código que trabalha com o runtime em vez de lutar contra ele. Nosso código em Rust era um excelente Rust. Mas, uma vez compilado para WASM e embutido em uma aplicação JavaScript, ele brigava com a plataforma a cada passo.
Pare de presumir, comece a medir
Eu ainda uso WASM. Tenho um pipeline de processamento de imagens baseado em WASM que é 4x mais rápido do que qualquer coisa que eu conseguiria escrever em JavaScript, porque opera sobre buffers de pixels brutos sem nenhuma travessia de fronteira. Ferramenta certa, trabalho certo.
Mas parei de usar WASM por padrão quando preciso de performance. Meu novo padrão é escrever primeiro a versão em TypeScript, medir com dados reais e só recorrer ao WASM quando o profiler indicar que preciso, e só para o hot path específico que de fato é lento. Não o módulo inteiro. Não a funcionalidade inteira. Apenas o loop computacional apertado que realmente se beneficia da compilação antecipada.
Escreva em TypeScript. Meça. Se for rápido o suficiente, publique. Se não for, mova o loop quente, e somente o loop quente, para WASM. Você vai acabar com menos código, bundles menores e aplicações mais rápidas.
A plataforma web oferece dois modelos de execução poderosos. Use os dois. Mas use-os onde realmente ajudam, não onde sua intuição diz que deveriam ajudar.


