Guía de WebAssembly (WASM) en aplicaciones web modernas
Cómo los binarios compilados desde C/C++ y Rust consiguen un rendimiento casi nativo dentro del navegador.
Qué es WebAssembly, exactamente
WebAssembly es un formato de instrucciones binarias y un destino de compilación. Casi nunca se escribe a mano. Un compilador toma C, C++, Rust, Zig o Go y genera un módulo; el navegador valida ese módulo y lo compila a código máquina. El formato es pequeño y aburrido a propósito: una máquina de pila, flujo de control estructurado en lugar de saltos arbitrarios y un sistema de tipos tan estricto que la validación es una sola pasada lineal sobre los bytes. Esas restricciones son la razón por la que un navegador puede empezar a compilar un módulo mientras todavía lo está descargando.
Lo que no es, en cambio, es un sustituto de JavaScript. Un módulo no tiene acceso al DOM, ni a la red, ni llamadas al sistema. Puede hacer aritmética, leer y escribir en su propia memoria y llamar a las funciones que el host le importa de forma explícita. Todo lo demás sigue siendo trabajo de JavaScript. En la práctica entregas las dos piezas: un módulo que hace el cálculo y una capa fina de JavaScript que le pasa los bytes y saca los resultados.
Por qué los códecs tienen su sitio en WebAssembly
El argumento de siempre es la velocidad, y plantearlo así esconde uno mejor. Los motores de JavaScript modernos son rápidos, a veces asombrosamente, pero lo consiguen especulando. El JIT observa qué tipos pasan por una función, compila una versión especializada y la protege con comprobaciones. Si le das un valor que no esperaba, la comprobación falla, el código se desoptimiza y vuelves a una ejecución más lenta hasta que se recupera. En el código de una aplicación eso ni se nota. En el bucle interno de un decodificador es un parón en mitad de un fotograma.
Un módulo WebAssembly lleva sus tipos dentro del binario. El compilador conoce la forma de cada valor antes de que se ejecute nada, así que no hay nada que adivinar ni nada que invalidar más tarde. La memoria lineal tampoco pasa por el recolector de basura, de modo que la ruta crítica no tiene pausas de recolección que sortear. En rendimiento máximo, los dos suelen estar más cerca de lo que se cree. La diferencia aparece en la regularidad, y la regularidad es lo que el usuario nota de verdad cuando una barra de progreso avanza a ritmo constante en lugar de a trompicones. Las instrucciones SIMD de ancho fijo de 128 bits también ayudan, porque los bucles sobre píxeles y sobre muestras de audio tienen justo la forma que mejor se vectoriza.
Luego está la razón que no tiene nada que ver con el rendimiento. Los códecs de imagen y vídeo, los renderizadores de PDF, los motores de OCR y los modelos de voz ya existen como bibliotecas maduras de C y C++ que han absorbido años de informes de errores y de casos límite con entradas malformadas. Reescribir libjpeg o Tesseract en JavaScript no es un proyecto de fin de semana, y durante mucho tiempo el resultado sería peor que el original. WebAssembly te permite compilar lo que ya existe y publicarlo. Por eso está ahí la mayor parte del WebAssembly que hay en producción, y es una razón más sólida que cualquier benchmark.
La memoria lineal y por qué una pestaña tiene techo
La memoria de un módulo es un único búfer contiguo. Los punteros del módulo son desplazamientos dentro de ese búfer, que crece en páginas de 64 KiB, y en wasm32 esos punteros tienen 32 bits, lo que limita la memoria direccionable a 4 GiB. Los navegadores se quedan bastante por debajo de esa cifra, y los móviles, mucho más abajo todavía. De ahí salen tres consecuencias, y son las que dan disgustos en producción.
El crecimiento, en la práctica, solo va en un sentido. memory.grow amplía el búfer, pero cuando el asignador del módulo libera un bloque, ese bloque vuelve al heap del propio módulo y no al navegador. Así que un solo trabajo grande eleva el pico de memoria de la pestaña durante todo el tiempo que viva esa instancia. Si procesas archivos grandes uno detrás de otro, destruir la instancia y crear una nueva entre trabajos es más fiable que confiar en que free devuelva algo.
Además, crecer exige una reserva contigua. Un grow puede fallar aunque a la máquina le quede mucha RAM libre, solo porque el espacio de direcciones está fragmentado. Pasa sobre todo en compilaciones de 32 bits y en móviles con poca memoria, y llega como una excepción a mitad de la operación, no como un rechazo educado al principio.
Por último, los datos hay que copiarlos de un lado a otro de la frontera. JavaScript no puede pasarle un objeto File a un módulo. Lees el archivo en un ArrayBuffer, copias esos bytes a la memoria lineal, ejecutas la operación y vuelves a copiar el resultado hacia fuera para construir un Blob. Durante un momento tienes la entrada dos veces, más la memoria de trabajo que necesite el propio códec. Calcula varias veces el tamaño del archivo, no una, pon un límite de tamaño explícito en la interfaz y procesa por streaming cuando el formato lo permita. Mejor un rechazo que escribiste tú que un fallo que no escribiste.
El binario que tienes que distribuir
Una compilación a WebAssembly de un códec serio se mide en megabytes, y no se comprime como JavaScript. El JavaScript minificado es texto repetitivo y a gzip le encanta. Un módulo compilado ya es binario compacto, así que la proporción es peor, aunque Brotli ayuda. Los modelos de aprendizaje automático lo ponen aún más difícil: el runtime es calderilla al lado de los pesos. La regla que se deduce es sencilla, y aun así hay quien se la salta. No cargues el módulo al cargar la página. Cárgalo cuando el usuario ya haya decidido hacer la operación.
Algunos detalles hacen que esa carga diferida duela menos. Sirve el archivo como application/wasm para que WebAssembly.instantiateStreaming pueda compilar a medida que llegan los bytes en lugar de acumular antes toda la descarga, porque con un tipo MIME incorrecto pierdes eso sin enterarte. Dale al archivo una URL con el hash del contenido y una cabecera de caché inmutable para que una visita repetida no pague nada. Los navegadores también guardan en caché el código compilado de los módulos grandes, así que quien vuelve puede ahorrarse la compilación además de la descarga.
Este sitio está construido sobre esa idea. En la carga inicial de la página no hay nada con forma de WebAssembly. La herramienta de OCR inyecta su motor solo cuando ejecutas el OCR, y el worker de transcripción descarga su modelo de voz la primera vez que transcribes y después tira de la caché del navegador. Quien convierte un JPG a PNG y se va no descarga nada de eso, porque ese camino no toca WebAssembly en absoluto. Es un dibujo en un canvas y una llamada de codificación, y de las dos cosas se encarga el código nativo del propio navegador.
Cuándo WebAssembly es la respuesta equivocada
No tiene acceso al DOM, así que cada operación sobre el DOM vuelve a JavaScript a través de código de enlace. Un framework de interfaz compilado a WebAssembly paga ese peaje en cada interacción. El cruce es barato, pero no gratis, y ocurre constantemente.
Con las cadenas de texto pasa algo parecido, y por la misma razón. No hay un tipo de cadena compartido, así que el texto se codifica, se copia y se decodifica en la frontera. Una carga de trabajo que consiste sobre todo en manipular cadenas puede pasar más tiempo moviendo datos de un lado a otro que calculando.
Los trabajos cortos rara vez amortizan la inversión. Descargar, compilar e instanciar cuestan tiempo real. Si el cálculo en sí tarda milisegundos, el módulo nunca lo recupera.
Y cuando la plataforma ya hace el trabajo con código nativo, distribuir tu propia copia es un paso atrás. WebCodecs codifica y decodifica vídeo. Web Audio se encarga de los grafos de audio. Canvas decodifica, redimensiona y vuelve a codificar imágenes. Envolver un remuestreador en un megabyte de WebAssembly para hacer lo que drawImage ya hace es la versión más clara de ese error.
Hay una cosa más, que es menos una limitación que una trampa. Las llamadas a WebAssembly son síncronas. Una llamada larga bloquea el hilo en el que se ejecuta, y en el hilo principal eso significa una página congelada, igual que con un bucle largo de JavaScript. Los módulos pesados van en un Worker. No es un consejo opcional.
Preguntas que conviene responder antes
- ¿Existe ya una biblioteca nativa madura que resuelva esto correctamente? Es la razón más fuerte para compilarla y la más débil para reimplementarla en JavaScript.
- ¿El trabajo es numérico, autocontenido y lo bastante largo para amortizar la descarga y la compilación? Los bucles ajustados sobre búferes tipados son el tipo de código que sale ganando.
- ¿Lo cubre ya una API del navegador? Si WebCodecs, Web Audio o canvas resuelven el caso, úsalos y no distribuyas nada.
- ¿Puede esperar el binario a que el usuario pida la operación, y puedes asumir la memoria que la instancia retendrá durante el resto de la vida de la pestaña?
- ¿Puede ejecutarse en un Worker? Si tiene que ir en el hilo principal y tarda más de un fotograma, la solución es moverlo, no optimizarlo.
La tecnología pierde todo el dramatismo en cuanto dejas de esperar que sustituya a JavaScript. Es una forma de ejecutar código nativo que ya existe dentro de un sandbox que nunca se diseñó para eso, con un modelo de memoria que hay que respetar y una descarga que hay que pagar. Trata esos dos costes como restricciones de diseño desde el primer día y el resto es ingeniería corriente.