WASM vs binario nativo: cómo se distribuye
Cómo llega el compilador a tu máquina y por qué eso decide su velocidad. La era del compilador en Go compilado a WebAssembly y el peaje de cruzar la frontera entre JavaScript y WASM en cada archivo. El modelo de Astro 7 con Rust: binarios nativos por plataforma vía napi-rs, con WASM como red de seguridad. Y por qué el motor nativo rinde más que la versión anterior.
Un compilador escrito en Go o en Rust no puede ejecutarse tal cual desde Node: hay que empaquetarlo de forma que el mundo de JavaScript sepa llamarlo. Cómo se empaqueta no es un detalle de fontanería, porque decide cuánto cuesta cada invocación, y un compilador se invoca miles de veces por build. Durante años Astro resolvió eso compilando su compilador de Go a WebAssembly; Astro 7, con el compilador en Rust, cambia a binarios nativos por plataforma y deja WASM como plan B. Entender las dos vías explica de dónde sale la ganancia de velocidad que se anuncia en 2026.
- Explicar cómo se distribuía el compilador de Go compilado a WebAssembly.
- Identificar el peaje de cruzar la frontera JavaScript-WASM en cada archivo.
- Describir el modelo nativo de Astro 7 con
napi-rsy su fallback a WASM. - Razonar por qué el binario nativo rinde más que la versión anterior en Go.
La era del compilador en Go sobre WASM
Durante casi toda su historia, el compilador de Astro fue un programa en Go que no se distribuía como binario suelto sino compilado a WebAssembly. El paquete @astrojs/compiler traía un archivo .wasm y un envoltorio de JavaScript que lo instanciaba y le hablaba. La elección tenía una virtud enorme: un solo .wasm corría igual en macOS, Linux y Windows, en cualquier arquitectura, sin compilar nada por plataforma. Instalabas Astro y el compilador funcionaba, fuera cual fuera tu máquina.
// modelo mental de la era WASM: instanciar el modulo y hablarle
const wasm = await WebAssembly.instantiate(bytesDelWasm, imports);
// cada llamada cruza la frontera JS <-> WASM llevando y trayendo texto
const resultado = wasm.exports.transform(fuenteSerializada);
La razón de elegir Go y WASM fue sensata en su momento. Go compila a WebAssembly con facilidad y produce un binario autocontenido, y WASM daba lo más difícil de conseguir de otro modo: un solo artefacto que corre en todas partes, sin pedir a nadie un compilador de C ni herramientas nativas por sistema. Para un proyecto que quería instalarse sin fricción en cualquier máquina, esa universalidad valía su precio. La decisión no fue un error que Astro 7 corrige; fue un equilibrio correcto para su época que las circunstancias han desplazado.
Esa portabilidad tenía contrapartida. Un módulo WASM vive en un espacio de memoria propio, aislado del de JavaScript, y no comparte objetos con él: solo puede intercambiar números y bloques de bytes. Para pasarle tu .astro había que serializar la cadena a esa memoria, y para recibir el resultado había que copiarlo de vuelta. Además, el runtime de Go arrastraba su propio recolector de basura dentro del .wasm, con su peso de arranque. Nada de esto se notaba en un archivo; multiplicado por miles, se convertía en una porción real del tiempo de build.
El peaje de la frontera
Conviene nombrar con precisión el coste, porque es el villano de esta historia. Cada vez que JavaScript llama a una función dentro de WASM y le pasa una cadena, ocurre un cruce de frontera: los datos se copian del montón de JavaScript a la memoria lineal del módulo, se procesan allí, y el resultado se copia de vuelta. Ese ida y vuelta no es gratis, y su coste crece con el tamaño de lo que se transfiere —y una fuente .astro con sus estilos y su marcado no es precisamente pequeña—.
La causa técnica es concreta y vale entenderla. WebAssembly, en su forma clásica, no tiene un tipo string: sus únicas monedas son enteros y flotantes, y toda estructura más rica —una cadena de texto, un objeto de resultado— hay que codificarla como bytes en una región de memoria que ambos lados acuerdan leer. Pasar tu componente al compilador significaba, literalmente, escribir sus caracteres byte a byte en esa región y decirle al módulo dónde empiezan y cuántos son; recuperar el resultado significaba hacer el mismo baile a la inversa. Ese protocolo de codificación y copia es el marshaling, y su coste es proporcional al texto que cruza, no al trabajo útil que se hace con él.
flowchart LR JS[mundo javascript] -->|serializar fuente| BR[frontera wasm] BR --> WASM[modulo wasm en Go] WASM -->|copiar resultado| BR2[frontera wasm] BR2 --> JS2[mundo javascript] style BR fill:#f9e2af,color:#11111b style BR2 fill:#f9e2af,color:#11111b
El peaje tiene dos componentes que conviene separar porque escalan distinto:
- El arranque: instanciar el módulo y levantar el runtime de Go cuesta un tiempo fijo cada vez que el proceso de build empieza. Se paga una vez por build.
- El marshaling: la copia de datos de ida y vuelta en cada llamada. Se paga una vez por archivo, así que crece con el tamaño del proyecto.
En un sitio de diez páginas ninguno de los dos importa; en uno de diez mil, el marshaling se multiplica hasta dominar la percepción de lentitud. La frontera WASM era el precio de la portabilidad —un único binario para todas las plataformas— y en proyectos grandes ese precio se hacía visible en cada build y cada arranque del dev server.
El modelo nativo de Astro 7
Astro 7 reescribe el compilador en Rust y cambia la estrategia de distribución. En lugar de un .wasm universal, publica binarios nativos por plataforma empaquetados como addons de Node mediante napi-rs, la tecnología que también usan herramientas como Rolldown, SWC u Oxc. Al instalar Astro, el gestor de paquetes descarga solo el binario .node que corresponde a tu sistema operativo y arquitectura, gracias a las dependencias opcionales que declaran cada variante.
// el paquete declara un binario por plataforma como dependencias opcionales
{
"optionalDependencies": {
"@astrojs/compiler-darwin-arm64": "7.0.0",
"@astrojs/compiler-linux-x64-gnu": "7.0.0",
"@astrojs/compiler-win32-x64-msvc": "7.0.0"
}
}
El nombre de cada dependencia codifica la plataforma exacta —sistema operativo, arquitectura y, en Linux, la librería C—, de modo que el gestor de paquetes resuelve sin ambigüedad cuál instalar y descarta el resto. Es el mismo esquema con el que se distribuyen los binarios de las herramientas nativas más usadas del ecosistema.
En tiempo de ejecución, el envoltorio de JavaScript del compilador prueba a cargar el binario nativo de tu plataforma y, si no existe, recae en el WASM. La lógica es tan simple como esto:
// esquema del cargador: primero lo nativo, si no el fallback universal
let compiler;
try {
compiler = require('@astrojs/compiler-' + platform); // addon .node
} catch {
compiler = await loadWasm(); // fallback universal
}
Ese require de un .node es una carga de módulo nativo de Node: el binario se enlaza al proceso y sus funciones quedan disponibles como llamadas ordinarias, sin runtime intermedio. La try que lo envuelve es toda la ceremonia que hace falta para que el fallback sea automático e invisible.
La diferencia de fondo es que un addon nativo no cruza una frontera de memoria aislada: comparte el proceso de Node, recibe la cadena sin copiarla a un espacio separado y devuelve el resultado sin marshaling. No hay runtime de Go que levantar, no hay serialización por archivo. Y como es Rust, el compilador puede repartir el trabajo entre todos los núcleos con seguridad de memoria garantizada por el lenguaje. Para las plataformas exóticas que no tengan binario, el paquete conserva un fallback a WASM: más lento, pero universal, de modo que nadie se queda sin compilar.
Nada de esta maquinaria asoma a tu código. Sigues llamando a transform con la misma firma que en la era WASM; lo que cambia es qué se carga por debajo cuando lo invocas —un .node nativo o, en su defecto, un .wasm—. Esa estabilidad de la interfaz es lo que permite que un cambio tan radical en la distribución no rompa ni un solo proyecto: la fachada del compilador es la misma, y solo se ha sustituido el motor que hay detrás de ella. La portabilidad no se pierde, se degrada con elegancia: primero se intenta lo rápido, y solo si no existe se recurre a lo universal.
WASM universal
Un solo binario para todas las plataformas. Maxima portabilidad, pero paga frontera y arranque en cada build.
Nativo por plataforma
Un binario .node por sistema via napi-rs. Sin frontera de memoria ni serializacion por archivo.
Paralelismo real
Rust reparte los componentes entre los nucleos con seguridad de memoria del lenguaje.
Fallback seguro
Si no hay binario para tu plataforma, queda el WASM como red de seguridad. Nadie se queda sin compilar.
El modelo de dependencias opcionales por plataforma con fallback a WASM no lo inventó Astro; es el patrón que ya usan esbuild, SWC, Oxc y Rolldown para distribuir herramientas nativas al ecosistema de Node. Astro 7 se sube a esa convención probada en vez de idear la suya. Por eso su instalación se comporta como la de esas otras herramientas: descarga silenciosa del binario correcto y, si acaso, un .wasm de reserva.
Que el binario dependa de la plataforma tiene una consecuencia práctica que conviene conocer: el que se instala en tu portátil no es el mismo que se instala en el servidor de integración continua si corren sistemas distintos. Por eso los gestores de paquetes registran todas las variantes en el lockfile pero solo descargan la que encaja con la máquina actual. Si alguna vez ves un aviso sobre una dependencia opcional que no se instaló, casi nunca es un error: es sencillamente el binario de otra plataforma que tu sistema no necesita.
Por qué el nativo rinde más
La ganancia de Astro 7 sobre la versión anterior no viene de que Rust sea mágicamente más rápido que Go —ambos son lenguajes compilados y veloces—, sino de haber quitado la frontera. Tres costes desaparecen a la vez:
- El arranque del runtime de Go dentro del
.wasm, que ya no existe. - La serialización de cada
.astroa la memoria del módulo y su copia de vuelta. - La traducción de estructuras entre dos mundos: el nativo comparte datos con el resto de la cadena de build.
Sumado al paralelismo que Rust permite con seguridad de memoria, el efecto se dispara justo donde antes dolía: los sitios con miles de componentes, donde el marshaling se pagaba archivo por archivo. La versión anterior no era lenta por Go; era lenta por la aduana que Go tenía que cruzar en cada llamada.
En el día a día, esa frontera eliminada se nota en tres momentos concretos, los mismos que más repites al desarrollar. El arranque del servidor de desarrollo ya no espera a instanciar un módulo WASM ni a levantar el runtime de Go. El HMR —recompilar el componente que acabas de tocar— corre a velocidad nativa, sin serializar nada a una memoria aislada. Y el build de producción empaqueta miles de módulos sin que el compilador sea el cuello de botella. No es una cifra abstracta de benchmark: es la diferencia entre una terminal que te hace esperar y una que responde antes de que apartes la vista.
No esperes ver la diferencia cronometrando la compilación de un componente aislado; ahí el peaje de la frontera era despreciable. La ventaja del binario nativo aparece cuando el número de archivos crece, porque el coste que se elimina —arranque y marshaling— se pagaba por build y por archivo. Mide sobre un proyecto grande, no sobre un hola mundo, o concluirás que el cambio no sirve de nada cuando en realidad es donde más rinde.
Hay una lección de ingeniería aquí que trasciende a Astro y conviene grabar. La intuición ingenua dice que para que un programa vaya más rápido hay que reescribirlo en un lenguaje más rápido, y por eso mucha gente resume el cambio de Astro como pasó de Go a Rust, por eso vuela. Pero esa lectura yerra el diagnóstico. Go no era lento; el compilador en Go era perfectamente veloz ejecutándose dentro de su mundo. Lo que costaba caro no era el cómputo, era el borde: el punto donde dos universos de memoria que no se conocen —el montón de JavaScript y la memoria lineal del módulo WASM— tenían que intercambiar datos copiándolos uno a uno, ida y vuelta, archivo tras archivo. El villano nunca fue el lenguaje del cómputo, sino la aduana entre lenguajes. Y esa aduana existía por una buena causa —la portabilidad de un único .wasm—, lo que revela la verdadera naturaleza de la decisión: no es Go contra Rust, es portabilidad contra integración. La era WASM eligió que un solo binario corriera en todas partes, y pagó ese regalo con una frontera en cada llamada. Astro 7 elige integrarse en el proceso nativo de Node, renuncia al binario único, y a cambio borra la frontera; conserva WASM solo como red por si la integración no es posible. Entender esto te vacuna contra un error de razonamiento muy común al elegir herramientas: creer que el rendimiento vive dentro de los componentes, cuando muchas veces vive en las costuras entre ellos. La pregunta afilada casi nunca es qué tan rápido corre cada parte, sino cuánto cuesta que las partes se hablen. Los sistemas grandes rara vez son lentos por su cómputo; son lentos por sus fronteras. Astro 7 es, en el fondo, la historia de una frontera eliminada.
- Instala Astro 7 en un proyecto y busca en
node_modulesel paquete del compilador; identifica si hay un binario.nodeespecífico de tu plataforma. - Localiza en el
package.jsondel compilador lasoptionalDependenciespor plataforma y razona cuál se instaló en tu sistema y por qué. - Explica, con tus palabras, qué copia de datos se ahorra un addon nativo frente al modelo WASM en cada llamada a
transform. - Argumenta por qué medir la mejora exige un proyecto con muchos archivos y no uno solo, conectándolo con dónde se pagaba el peaje de la frontera.