DEV Community

Moon Robert
Moon Robert

Posted on • Originally published at blog.rebalai.com

WebAssembly en 2026: Dónde Realmente Tiene Sentido Reemplazar JavaScript

Llevo ignorando WebAssembly desde más o menos 2022. No porque pensara que era mala tecnología — era más que cada vez que lo intentaba, el dolor de la toolchain superaba los beneficios reales que obtenía. Lo probé con Emscripten para portar una librería de C++, pasé dos días configurando y al final el bundle pesaba 4MB y el startup time era horrible. Lo dejé estar.

Pero algo cambió este enero.

Estaba trabajando en un proyecto de procesamiento de imágenes para un cliente — una web app que necesitaba aplicar filtros y transformaciones complejas en el cliente, sin enviar datos al servidor por razones de privacidad. El equipo éramos tres personas. Y por primera vez en años, WASM no solo tuvo sentido sino que fue claramente la decisión correcta. Pasé dos semanas yendo y viniendo entre implementaciones en JavaScript puro y Rust compilado a WASM, y lo que encontré cambió bastante cómo pienso en todo esto.


Los números que me convencieron (y los que no tanto)

Empecé con algo concreto: un filtro de convolución 5×5 aplicado a imágenes de 4K. Nada del otro mundo algorítmicamente, pero computacionalmente intenso si lo haces en JavaScript puro iterando sobre ImageData.

La implementación en JS, optimizada razonablemente con TypedArrays y sin objetos intermedios:

// JS puro — sin librerías, TypedArrays directos
function convolve5x5(data, width, height, kernel) {
  const output = new Uint8ClampedArray(data.length);
  const offset = 2; // Math.floor(5 / 2)

  for (let y = offset; y < height - offset; y++) {
    for (let x = offset; x < width - offset; x++) {
      let r = 0, g = 0, b = 0;
      for (let ky = 0; ky < 5; ky++) {
        for (let kx = 0; kx < 5; kx++) {
          const px = ((y + ky - offset) * width + (x + kx - offset)) * 4;
          const kv = kernel[ky * 5 + kx];
          r += data[px]     * kv;
          g += data[px + 1] * kv;
          b += data[px + 2] * kv;
        }
      }
      const out = (y * width + x) * 4;
      output[out]     = Math.min(255, Math.max(0, r));
      output[out + 1] = Math.min(255, Math.max(0, g));
      output[out + 2] = Math.min(255, Math.max(0, b));
      output[out + 3] = data[out + 3]; // preservar alpha
    }
  }
  return output;
}
Enter fullscreen mode Exit fullscreen mode

Resultado promedio en Chrome 133, imagen de 3840×2160: ~820ms.

La misma lógica en Rust, compilada con wasm-pack 0.13.1 y wasm-opt -O3:

// Rust — wasm-bindgen genera el glue de JS automáticamente
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn convolve_5x5(
    data: &[u8],
    width: usize,
    height: usize,
    kernel: &[f32],
) -> Vec<u8> {
    let mut output = vec![0u8; data.len()];
    let offset = 2usize;

    for y in offset..(height - offset) {
        for x in offset..(width - offset) {
            let (mut r, mut g, mut b) = (0f32, 0f32, 0f32);
            for ky in 0..5usize {
                for kx in 0..5usize {
                    let px = ((y + ky - offset) * width + (x + kx - offset)) * 4;
                    let kv = kernel[ky * 5 + kx];
                    r += data[px]     as f32 * kv;
                    g += data[px + 1] as f32 * kv;
                    b += data[px + 2] as f32 * kv;
                }
            }
            let out = (y * width + x) * 4;
            output[out]     = r.clamp(0.0, 255.0) as u8;
            output[out + 1] = g.clamp(0.0, 255.0) as u8;
            output[out + 2] = b.clamp(0.0, 255.0) as u8;
            output[out + 3] = data[out + 3];
        }
    }
    output
}
Enter fullscreen mode Exit fullscreen mode

Resultado: ~95ms. Casi 9× más rápido.

Ese número hay que tomarlo con contexto. El módulo WASM pesa 180KB gzippeado y tiene un overhead de instanciación de 15-40ms la primera vez en Chrome. Para una operación que el usuario dispara una sola vez en toda la sesión, ese costo inicial puede importar. Para algo que se repite — y en este proyecto se repetía constantemente — se amortiza rápido.

Lo que no esperaba: en Firefox 134 el salto fue de solo ~5.5× con los mismos datos. No encontré una explicación definitiva. Sospecho que tiene que ver con cómo el JIT de Firefox optimiza bucles numéricos densos sobre TypedArrays, que quizás ya los vectoriza mejor que Chrome internamente. Tu kilometraje puede variar.


Los tres casos donde WASM gana de forma consistente

Después de las dos semanas, y de hablar con un par de personas del equipo que habían experimentado cosas similares, llegué a tres categorías donde lo usaría sin pensarlo dos veces.

Procesamiento de medios en el cliente. Este fue exactamente el caso del proyecto de privacidad — transformaciones de imagen, codificación de audio, muxing de video, cualquier cosa que implique iterar sobre millones de bytes con operaciones aritméticas. ffmpeg.wasm en 2026 ya no es el desastre de startup que era en 2023; el tiempo de instanciación bajó considerablemente con las mejoras en compilación streaming. Sigue sin ser trivial de integrar, pero al menos ya no me hace querer tirar el laptop.

Criptografía computacionalmente costosa. Los números aquí son brutales. Argon2id en JavaScript es prácticamente inutilizable con parámetros de seguridad reales — en un proyecto de notas cifradas end-to-end que terminé en noviembre, medir argon2 con memory_cost=65536 e iterations=3 daba ~4500ms en JS puro. Con WASM: ~280ms, mismos parámetros. La diferencia entre "el usuario espera casi cinco segundos" y "el usuario no nota nada" es lo que decide si un feature existe o no.

Portar librerías probadas de otros lenguajes. Si ya existe código de C/C++/Rust con años de tests y auditorías, reescribirlo en JS es tiempo, riesgo de bugs de compatibilidad y potenciales regresiones de seguridad. Aquí Emscripten 3.1.x ha madurado bastante — sigue siendo más verboso que wasm-pack para código Rust nuevo, pero para bindings de librerías existentes es la opción pragmática. No me pagan por reescribir código que ya funciona.


El problema del boundary que descubrí de la peor manera

Aquí viene la parte donde pensé que entendía WASM y resultó que no.

Estaba seguro de que si WASM era 9× más rápido en el benchmark aislado, también optimizaría la detección de bordes que hacía frame a frame sobre un canvas de 720p a 30fps. La lógica parecía sólida: operación intensa, muchas multiplicaciones, candidato perfecto.

Lo implementé. Lo medí. Era más lento que JavaScript.

Pasé un día entero convencido de que había un bug en mi Rust. No había bug.

El problema era el boundary crossing. Cada vez que JavaScript llama a una función WASM y le pasa datos, hay un overhead de copia de memoria — el linear memory de WASM y el heap de JS son espacios separados, no hay zero-copy por defecto. Para el filtro de convolución, pasaba el buffer de imagen una vez, procesaba todo internamente y devolvía el resultado. El overhead del boundary era insignificante comparado con el trabajo real.

Para el detector de bordes por frames, estaba llamando a la función WASM 30 veces por segundo, cada vez copiando un buffer de ~3.3MB (1280×720×4 bytes). Esa copia era más costosa que simplemente ejecutar el algoritmo en JS. La función WASM era rápida. El problema era que la llamaba demasiadas veces con datos demasiado grandes.

La solución fue mover el bucle de frames también dentro de WASM, pasar los frames por SharedArrayBuffer desde un Worker, y dejar que WASM manejara la acumulación por completo. Funcionó, pero la complejidad de setup subió bastante. Y honestamente, en retrospectiva, para ese caso específico WebGPU hubiera sido más directo.

Esto es lo que más me cambió el criterio: la granularidad de las llamadas importa tanto como el volumen total de cómputo. Una sola llamada que procesa 10MB de golpe es completamente diferente a 300 llamadas que procesan 33KB cada una.


El stack que uso actualmente

Para proyectos donde WASM tiene sentido, mi configuración en 2026:

  • Rust + wasm-bindgen + wasm-pack 0.13.x para el módulo. wasm-bindgen genera automáticamente el glue code de JS y los tipos TypeScript, lo cual en un equipo pequeño vale mucho — no quiero mantener bindings a mano.
  • wasm-opt -O3 en el pipeline de CI. El .wasm sin optimizar puede ser 2-3× más grande. No lo saltes nunca.
  • Web Workers para el módulo WASM. Si el cómputo tarda más de ~50ms, no puede estar en el hilo principal. Esto no es negociable si te importa el tiempo de respuesta de la UI.
  • Lazy loading con await import(). El módulo WASM no va en el bundle principal — se carga solo cuando el usuario necesita esa funcionalidad por primera vez.

Lo que no uso: AssemblyScript. Lo intenté en 2024 para un proyecto interno y la experiencia fue extraña — el lenguaje se siente como TypeScript pero las restricciones de memoria te obligan a pensar de formas que se sienten artificiales para el tipo de problemas que estaba resolviendo. WasmGC ya está en todos los browsers principales desde mediados de 2024, y eso teóricamente mejora la situación para AssemblyScript y para lenguajes como Kotlin/Dart. Pero yo ya estaba en Rust para estas cosas y no he tenido una razón convincente para cambiar.

Mira, que Dart/Flutter Web esté usando WasmGC en producción es interesante para equipos de ese ecosistema. Para mí, haciendo proyectos web-first, no ha cambiado el día a día.


Dónde no lo usaría aunque me pagaran

WASM no tiene acceso directo al DOM. Cada operación sobre el árbol de elementos pasa por el bridge de JS. Si tu cuello de botella está en manipulación del DOM, WASM no resuelve nada y añade capas de complejidad innecesarias. Revisa batching de actualizaciones, CSS containment, o simplemente perfila antes de sacar conclusiones.

Las aplicaciones CRUD normales — fetch, JSON, validación de formularios, gestión de estado — no son computacionalmente intensas de formas que WASM resuelva. He visto propuestas de "vamos a reescribir todo en Rust/WASM" y en los casos que he podido ver de cerca, el resultado fue más lento de desarrollar y el rendimiento percibido no mejoró porque el cuello de botella real era el servidor, la red, o el tiempo de renderizado — no el cliente.

El debugging también es una razón práctica para pensar bien si tu equipo está listo. Las source maps funcionan, las herramientas de Chrome DevTools han mejorado desde 2023, pero un panic en Rust o una violación de memoria en C++ en producción a las 2am sigue siendo una experiencia muy diferente a depurar JavaScript. Lo sé porque un viernes a las 6pm tuve que depurar un lifetime issue en Rust con el cliente en la llamada esperando un hotfix. No fue divertido.


Después de todo esto, mi criterio es bastante concreto: WASM vale la pena cuando tienes una operación bien delimitada que procesa grandes bloques de datos en pocas llamadas y el cómputo es el cuello de botella real — procesamiento de medios, criptografía pesada, simulaciones numéricas, parsers de formatos binarios. Ahí la ganancia justifica el overhead de toolchain, la complejidad de debugging, y la curva de aprendizaje si tu equipo no viene de lenguajes de sistemas.

Para todo lo demás, JavaScript en 2026 es más que suficiente. Sé que no es la respuesta emocionante, pero la madurez del ecosistema JS sigue siendo inalcanzable y el gap de rendimiento para trabajo web normal es bastante menor de lo que sugieren los benchmarks que circulan por Twitter.

Top comments (0)