Artículos en profundidad sobre la tecnología que da forma al futuro.

Cuando WebAssembly no es más rápido que JavaScript

WebAssembly no siempre supera a JavaScript. Benchmarks reales muestran cuándo TypeScript gana a WASM y por qué cruzar la frontera mata el rendimiento.

Corredor detenido en un control fronterizo mientras otro pasa corriendo libremente

El mes pasado dediqué un sábado a reescribir en TypeScript un parser de metadatos de imágenes que estaba hecho en Rust y compilado a WASM. La versión WASM llevaba ocho meses en producción. Funcionaba bien. Nadie se quejaba. Pero llevaba un rato mirando nuestras trazas de rendimiento y algo me molestaba: el parser en WASM pasaba más tiempo moviendo datos a través de la frontera JS-WASM que parseando de verdad. Así que lo reescribí. TypeScript puro, sin WASM. El resultado fue un 38% más rápido para nuestra carga mediana. No escribí mejores algoritmos. Simplemente dejé de pagar el peaje de cruzar la frontera.

Esa experiencia me removió algo. Yo era de los desarrolladores que asumían que WebAssembly era la opción rápida, sin más. Resulta que esa suposición está equivocada más a menudo de lo que la mayoría creemos.

El mito de que «WASM siempre es más rápido» tiene que morir

El modelo mental que la mayoría de desarrolladores tiene es este: un lenguaje compilado va a WASM, WASM corre a velocidad casi nativa, por lo tanto WASM es más rápido que JavaScript. Cada paso de esa cadena es más o menos cierto. Pero la conclusión no se sostiene, porque ignora el coste de todo lo que rodea al cálculo: meter datos, sacar resultados y la sobrecarga de ejecutar dos runtimes en paralelo.

Los motores de JavaScript son una locura de buenos. V8, SpiderMonkey y JavaScriptCore representan décadas de trabajo de optimización financiado por algunas de las empresas más ricas del planeta. Hacen compilación JIT especulativa, inline caching, escape analysis y transiciones de clases ocultas. Para muchas cargas de trabajo, sobre todo los patrones con mucho texto, muchos objetos y muchos callbacks típicos de las aplicaciones web, un motor JS moderno genera código máquina sorprendentemente parecido al que daría un compilador estático.

WASM no recibe esas optimizaciones. Se compila AOT: lo que envías es lo que se ejecuta. Eso es una ventaja en cuanto a predictibilidad, pero significa que WASM no puede adaptarse a los patrones de ejecución en tiempo real como lo hace un JIT.

El problema de cruzar la frontera: morir por mil llamadas

Este es el gran problema. Cada llamada desde JavaScript hacia WASM (o de vuelta) tiene un coste: el motor tiene que serializar datos, validar tipos y cambiar de contexto de ejecución. Una sola llamada es barata, unos pocos microsegundos. Pero las cargas de trabajo que cruzan la frontera miles de veces por operación quedan aplastadas por esa sobrecarga.

Veo este patrón constantemente: un equipo escribe un parser, transformador o validador en Rust, lo compila a WASM y lo envuelve en código de pegamento en JavaScript que llama a WASM por cada nodo, cada token, cada paso. El núcleo en WASM puede ser rapidísimo de forma aislada, pero el código de pegamento lo convierte en una autopista de peaje.

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

Hice un benchmark de este escenario exacto con una base de código real. La versión WASM procesó un lote de 100 archivos de configuración en 340 ms. El port a TypeScript lo hizo en 195 ms. No porque TypeScript sea un lenguaje más rápido (no lo es), sino porque el trabajo nunca salió de un único contexto de ejecución.

Por qué los strings destrozan el rendimiento de WASM

WASM opera sobre memoria lineal, un buffer plano de bytes. Los strings de JavaScript son una bestia completamente distinta: son objetos gestionados por el motor y guardados en formatos internos especializados (Latin1, UTF-16, ropes, cons strings). Pasar un string de JS a WASM implica codificarlo en UTF-8, reservar espacio en la memoria lineal y copiar los bytes. Sacarlo de vuelta implica copiar otra vez y decodificar.

Para código de cálculo numérico, esto no importa. Pasas un TypedArray, recibes números. Pero para cualquier cosa que toque strings intensivamente (parsers, motores de plantillas, validadores, serializadores), estás pagando el impuesto de codificar, copiar, procesar, copiar y decodificar en cada fragmento de texto.

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

Perfilé nuestro parser en WASM y descubrí que el 31% del tiempo total de ejecución era serialización de strings. No parseo. No construcción del árbol. Solo mover strings de un lado a otro a través de la frontera. La versión en TypeScript eliminó por completo esa categoría de trabajo.

Si tu camino crítico es intensivo en strings, lo más probable es que WASM lo esté volviendo más lento, no más rápido. La sobrecarga de serialización es real y se acumula muy rápido.

Compilación JIT: la ventaja de rendimiento de la que nadie habla

El modelo AOT de WASM te da un rendimiento predecible y consistente. Eso es genial para algunos casos de uso. Pero el modelo JIT de JavaScript hace que V8 observe tu código mientras se ejecuta y luego genere código máquina optimizado para los datos reales que circulan por él. Con el tiempo, el código compilado por el JIT puede ser absurdamente rápido.

Tomemos la especialización por forma de objeto. Si procesas un array de objetos que tienen las mismas propiedades en el mismo orden, V8 detecta ese patrón monomórfico y genera código máquina que accede a las propiedades mediante un desplazamiento fijo en memoria: sin búsqueda en tabla hash, sin comprobación de tipos. Es básicamente rendimiento de struct de C desde 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.

WASM no puede hacer esto. Sus características de rendimiento quedan fijadas en tiempo de compilación. Para bucles numéricos ajustados y predecibles, está bien: el compilador de Rust ya lo optimizó hasta el límite. Pero para el código desordenado, polimórfico y lleno de callbacks que caracteriza a la mayoría de aplicaciones web, la capacidad del JIT de especializarse en tiempo de ejecución es una ventaja real.

Tamaño del bundle y arranque en frío: las métricas que estás olvidando

El throughput no es la única métrica de rendimiento. Los usuarios experimentan el tiempo de carga, la latencia de interacción y el tiempo hasta el primer resultado. WASM tiene costes reales en las tres.

  • Un módulo de Rust con wasm-bindgen suele pesar entre 100 KB y 1,5 MB comprimido con gzip. El equivalente en TypeScript, minificado y comprimido, suele rondar los 10-40 KB.
  • Los módulos WASM deben compilarse a código nativo antes de ejecutarse. La compilación en streaming ayuda, pero sigue siendo trabajo que el navegador tiene que hacer.
  • Los motores de JavaScript parsean de forma perezosa: las funciones no se compilan hasta que se llaman. WASM paga el coste completo de compilación por adelantado.
  • El code cache de V8 hace que las visitas repetidas se salten por completo el parseo de JS. La caché de compilación de WASM existe, pero está menos madura.

Aquí tienes una comparación real de nuestro proyecto. El bundle del parser en WASM pesaba 380 KB comprimido con gzip. El reemplazo en TypeScript pesa 26 KB. Con una buena conexión, da igual. Pero con una conexión 3G limitada (que simulo en cada revisión de rendimiento), la versión WASM añadía 1,8 segundos de carga. Eso no es un error de redondeo: es la diferencia entre que un usuario se quede o se vaya.

El arranque en frío también importó. El módulo WASM tardaba unos 110 ms en compilarse e instanciarse en un teléfono de gama media. La versión en TypeScript era interactiva en menos de 20 ms. Para una herramienta que los usuarios usan decenas de veces al día, eso se convierte en frustración real.

Cuándo WASM sí gana (y cuándo deberías usarlo)

No quiero dar la impresión de que WASM es una mala tecnología. Es una gran tecnología con un punto dulce específico. El problema es que la gente lo usa fuera de ese punto dulce y luego se pregunta por qué las cosas se volvieron más lentas.

WASM gana cuando el cálculo es pesado, los cruces de frontera son pocos y los datos son numéricos. Piensa en filtros de imagen sobre buffers de píxeles, hashing criptográfico, simulaciones físicas, DSP de audio, códecs de vídeo. Metes un bloque grande de datos en la memoria lineal, dejas que WASM lo procese en un bucle ajustado y devuelves el resultado. Un cruce de entrada, un cruce de salida y muchísimo cálculo en medio. Ahí es donde WASM brilla.

WASM también gana cuando necesitas rendimiento determinista. La compilación JIT es potente pero impredecible: las primeras llamadas a una función JS se ejecutan interpretadas, luego con compilación baseline y quizá después con compilación optimizadora. Si necesitas latencia constante desde la primera invocación (bucles de juego, audio en tiempo real, cálculos financieros), la compilación anticipada de WASM te la da.

Y WASM es claramente la decisión correcta cuando estás portando una base de código nativa existente. Nadie debería reescribir SQLite en JavaScript para evitar la sobrecarga de WASM. Sería una locura. El código C existente representa décadas de optimización que nunca podrías replicar.

Un marco práctico para decidir: ¿WASM o TypeScript?

Después de pasar por este ejercicio en tres proyectos, he llegado a una checklist sencilla. No es perfecta, pero ya me ha evitado tomar la decisión equivocada dos veces.

  1. Cuenta los cruces de frontera. Si tu módulo WASM llama a JS (o al revés) más de unos cientos de veces por operación, TypeScript probablemente gane. Perfílalo para estar seguro.
  2. Mira tus tipos de datos. ¿Sobre todo números y TypedArrays? WASM encaja bien. ¿Sobre todo strings, objetos y callbacks? Quédate en JS.
  3. Mide el coste de arranque. Si el código se ejecuta al cargar la página o en respuesta a clics del usuario, la sobrecarga de compilación de WASM importa. Si corre en un worker de larga duración, el arranque se amortiza.
  4. Revisa tu presupuesto de bundle. Si ya tienes problemas con el tamaño, añadir un módulo WASM de 400 KB va a doler.
  5. Considera el coste de DX. WASM añade un sistema de build de Rust o C++, complejidad en los source maps y depuración más difícil. Si la ganancia de rendimiento es marginal, la complejidad no vale la pena.
  6. Mide con datos reales. Los microbenchmarks mienten. Los patrones de sobrecarga solo aparecen con entradas de tamaño de producción y patrones de llamadas realistas.

Lo que aprendí de la reescritura: antes y después

Déjame mostrarte los números reales de la migración de nuestro parser. No son benchmarks sintéticos: vienen de procesar nuestro corpus real de 200 archivos de entre 50 y 2.000 líneas.

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 reescritura en TypeScript no fue un port directo. Rediseñé la representación del AST para que se llevara bien con el optimizador de V8: formas de objeto monomórficas, enums numéricos en lugar de etiquetas de string, almacenamiento en arrays planos en lugar de árboles llenos de punteros. Son optimizaciones en las que nunca pensarías en Rust porque el compilador de Rust se encarga de ese nivel de detalle. En JavaScript, tienes que ir a medio camino del JIT.

// 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 clave? El código más rápido no es el escrito en el lenguaje más rápido. Es el que trabaja con su runtime en lugar de en contra de él. Nuestro código en Rust era un Rust excelente. Pero una vez compilado a WASM e incrustado en una aplicación JavaScript, peleaba contra la plataforma en cada momento.

Deja de suponer, empieza a medir

Sigo usando WASM. Tengo un pipeline de procesamiento de imágenes basado en WASM que es 4 veces más rápido que cualquier cosa que pudiera escribir en JavaScript, porque opera sobre buffers de píxeles crudos sin cruces de frontera. Herramienta correcta, trabajo correcto.

Pero he dejado de usar WASM por defecto cuando necesito rendimiento. Mi nuevo predeterminado es escribir primero la versión en TypeScript, medirla con datos reales y recurrir a WASM solo cuando el profiler me diga que lo necesito, y solo para el camino crítico concreto que es realmente lento. No el módulo entero. No la funcionalidad entera. Solo el bucle computacional ajustado que de verdad se beneficia de la compilación anticipada.

Escríbelo en TypeScript. Mídelo. Si es lo bastante rápido, despliégalo. Si no lo es, mueve el bucle caliente (y solo el bucle caliente) a WASM. Acabarás con menos código, bundles más pequeños y aplicaciones más rápidas.

La plataforma web te da dos modelos de ejecución poderosos. Úsalos ambos. Pero úsalos donde realmente ayudan, no donde tu intuición dice que deberían.