wandres.dev
WASM Y JS · los otros backends

Kotlin/Wasm: la recolección de basura que cambió las reglas

WebAssembly nació con un modelo de memoria pensado para C y Rust, y ese diseño convertía a todo lenguaje con recolector en un ciudadano de segunda obligado a traerse su propio runtime dentro del binario. Esta lección explica qué añade la propuesta WasmGC, por qué su llegada es la condición que hace viable un backend de Kotlin para la web binaria, cómo se traduce eso en una matriz de soporte de navegadores que es propia de Kotlin y no de WebAssembly en general, y cuál es el estado real del objetivo en 2026.

⏱ 20 min

WebAssembly se estandarizó con un modelo de memoria de una simplicidad brutal: un único vector de bytes que crece, direccionado por enteros, sin más estructura que la que el programa decida imponerle. Ese diseño era una carta de amor a C y a Rust, lenguajes que ya gestionaban la memoria a mano y para los que un bloque plano de bytes es exactamente el sustrato natural. Para un lenguaje con recolección de basura era, en cambio, una condena de doble filo: si la plataforma no sabe qué es un objeto, el lenguaje tiene que traerse su propio recolector compilado dentro del artefacto, de modo que un binario que apenas imprime un saludo acaba pesando cientos de kilobytes; y además los objetos que ese recolector gestiona resultan invisibles para el recolector del navegador, con lo que cualquier ciclo de referencias que cruce la frontera no se libera jamás. La propuesta WasmGC existe para deshacer ese nudo, y su adopción es lo que explica a la vez que Kotlin tenga hoy un backend de WebAssembly viable y que ese backend arrastre una matriz de soporte de navegadores propia, distinta de la del resto de WebAssembly.

🎯 Al terminar esta lección sabrás
  • Explicar qué añade la propuesta WasmGC al modelo original de WebAssembly y qué problema concreto resuelve.
  • Justificar por qué un lenguaje con recolector no podía compilar bien a WebAssembly antes de WasmGC.
  • Situar el estado real de Kotlin/Wasm en 2026: fase de madurez, navegadores mínimos y qué piezas ya son estables.
  • Distinguir el objetivo wasmJs del objetivo wasmWasi y reconocer cuándo aplica cada uno.

El WebAssembly original y el impuesto del recolector

El WebAssembly de la primera especificación ofrece una máquina virtual de pila con cuatro tipos numéricos, funciones, y una memoria lineal: un espacio de bytes contiguo que el módulo lee y escribe mediante desplazamientos enteros. No hay noción de objeto, ni de referencia, ni de campo. Un puntero es un Int, y la estructura de datos que ese entero designa existe únicamente en la cabeza del compilador que emitió el código. Para C esto no es una limitación sino una descripción fiel de lo que C ya hacía.

Conviene apreciar la elegancia del diseño antes de criticarlo. Una memoria lineal es verificable de forma trivial, porque el único invariante que hay que comprobar es que ningún acceso se salga de los límites del vector; es portable, porque no supone nada sobre la máquina anfitriona; y es determinista, porque no hay ningún subsistema que decida por su cuenta interrumpir el programa para hacer limpieza. Estas tres propiedades son exactamente las que hacían falta para que un navegador aceptara ejecutar código binario ajeno.

Un lenguaje con recolección de basura llega a esa plataforma con una necesidad que la plataforma no contempla: alguien tiene que saber qué regiones de la memoria siguen siendo alcanzables. Como el motor no lo sabe, la única salida es compilar el recolector completo y meterlo dentro del módulo. De ahí salen tres costes que conviene enumerar por separado, porque no se resuelven igual.

🕸️

Tamaño del artefacto

El recolector, el gestor de montículo y las estructuras de metadatos de tipos viajan en cada binario. La cota inferior del hola mundo deja de ser unos pocos kilobytes.

🧬

Dos montículos ajenos

El recolector del módulo y el del motor no se conocen. Un ciclo que pase por ambos es basura que ninguno de los dos puede probar que lo sea.

🐌

Recolección a ciegas

El recolector compilado no ve los registros ni la pila reales del motor, así que debe conservar mapas propios y trabajar con información peor que la del recolector nativo.

Este es el motivo por el que durante años la web binaria fue territorio de lenguajes sin recolector, y por el que los intentos de portar lenguajes gestionados producían artefactos que solo tenían sentido para aplicaciones muy grandes, donde el coste fijo del runtime se amortiza contra megabytes de lógica propia.

El segundo coste de la lista merece un párrafo aparte porque es el menos evidente y el más difícil de diagnosticar en producción. Un recolector demuestra que un objeto es basura probando que ningún camino lo alcanza desde las raíces; para poder probarlo necesita ver el grafo entero. Con dos recolectores independientes, cada uno ve la mitad del grafo y ambos deben suponer que todo lo que apunta hacia el otro lado sigue vivo, porque no pueden demostrar lo contrario. Una estructura circular que pase por ambos montículos es entonces basura genuina que ninguno de los dos está autorizado a recoger, y el resultado es una fuga que no aparece en ningún perfilador porque, desde el punto de vista de cada recolector por separado, el programa se está comportando de forma impecable.

Qué añade WasmGC

Antes de describir la solución conviene descartar las que no funcionaron, porque su fracaso delimita el problema. Se intentó reducir el recolector compilado a su mínima expresión, y el resultado fue recolectores peores que seguían pesando decenas de kilobytes. Se intentó no recolectar en absoluto, dejando que la memoria creciera hasta que la pestaña se cerrara, estrategia defendible en una utilidad efímera y ruinosa en una aplicación que vive horas. Y se intentó ofrecer al módulo una interfaz para consultar las raíces del motor, que habría atado la especificación a los detalles internos de cada implementación. Ninguna de las tres atacaba la causa, que era que el módulo pretendía hacer un trabajo para el que no tenía la información necesaria.

La propuesta de recolección de basura no introduce un recolector nuevo: introduce tipos. Añade a WebAssembly la capacidad de declarar estructuras y vectores con campos tipados y de manipular referencias a ellos, dejando que sea el motor anfitrión quien los sitúe, los recorra y los libere con el mismo recolector que ya ejecuta para JavaScript. El módulo deja de gestionar memoria y pasa a describir formas de datos.

La consecuencia es que ese material tan caro deja de viajar en el binario, que los objetos del módulo y los del motor viven en el mismo montículo y por tanto los ciclos entre ambos se recogen sin ceremonia, y que las mejoras que el equipo del navegador introduzca en su recolector benefician al código Kotlin sin recompilarlo.

flowchart TD
A[Lenguaje con recolector] --> B{Modelo de memoria disponible}
B -- Memoria lineal --> C[Recolector compilado dentro del modulo]
C --> D[Artefacto grande y dos monticulos ajenos]
B -- WasmGC --> E[Structs y arrays declarados como tipos]
E --> F[El motor recolecta con su propio GC]
F --> G[Artefacto menor y monticulo compartido]

Kotlin/Wasm no compila a memoria lineal: compila a WasmGC. Esta frase, que parece un detalle de implementación, es la que gobierna casi todo lo que un ingeniero necesita saber del backend, incluido el punto que más confusión genera, que es el de la compatibilidad.

Conviene precisar qué significa exactamente que el módulo declare tipos. Una clase de Kotlin no se convierte en una convención sobre desplazamientos dentro de un bloque de bytes, sino en una entrada de la sección de tipos del binario, con sus campos, su tipo para cada uno y su relación de subtipado con la clase madre. El motor lee esa sección antes de ejecutar la primera instrucción y la valida; a partir de ahí, el acceso a un campo es una instrucción que el verificador ya comprobó, no un cálculo aritmético que podría apuntar a cualquier sitio.

// En el binario, esto es un tipo declarado con dos campos,
// no un bloque de bytes con desplazamientos convenidos
class Pedido(val id: Long, val importe: Double)

De ahí se siguen dos propiedades que no son evidentes y que conviene retener. La primera es que no existe corrupción de memoria posible dentro del módulo: no hay aritmética de punteros, no hay conversiones entre tipos incompatibles y el verificador rechaza cualquier binario que lo intente, de modo que familias enteras de vulnerabilidad clásicas desaparecen por construcción y no por disciplina. La segunda es que el motor puede aplicar a esos objetos las mismas optimizaciones que ya aplica a los suyos, incluida la disposición compacta de los campos y las estrategias generacionales de recolección, sin que el compilador de Kotlin tenga que reimplementar nada.

ℹ️
La matriz de soporte es la de WasmGC, no la de WebAssembly

WebAssembly lleva ejecutándose en todos los navegadores desde 2017, pero eso no dice nada sobre Kotlin/Wasm. Lo que un binario de Kotlin necesita es un motor que implemente la propuesta de recolección de basura y la de manejo de excepciones. WasmGC alcanzó cobertura en Chrome a partir de la versión 119, en Firefox a partir de la 120 y en Safari a partir de la 18.2, con lo que la disponibilidad transversal se completó a finales de 2024. Antes de esa fecha no había Kotlin/Wasm en Safari; después, el objetivo pasa a cubrir todos los navegadores modernos con una frontera nítida y fácil de comunicar.

El estado real en 2026

Conviene separar tres cosas que el discurso comercial tiende a mezclar: la madurez de la propuesta de la plataforma, la madurez del backend de Kotlin y la madurez de las herramientas de construcción.

La plataforma

Cerrada y disponible en todos los motores actuales. La semántica de la memoria y de las excepciones no va a cambiar.

🚧

El backend

Declarado en Beta. Lo que puede moverse es la superficie de interoperabilidad, no la corrección del código emitido.

⚙️

Las herramientas

Compilación incremental estable y activa por defecto desde Kotlin 2.4. El ciclo de desarrollo deja de ser el obstáculo.

La propuesta de plataforma está resuelta. La recolección de basura y el manejo de excepciones son parte de lo que implementan todos los motores actuales, y su semántica no va a cambiar bajo los pies de nadie. Merece la pena subrayar que son dos propuestas y no una: sin la segunda, una excepción de Kotlin no podría atravesar el límite del módulo, y toda la maquinaria del lenguaje que descansa en el desenrollado de la pila, incluida la cancelación de corrutinas, quedaría sin sustrato.

El backend de Kotlin sigue declarado en Beta. Esto significa exactamente lo que dice la política de estabilidad del lenguaje: es usable en producción con criterio, pero la superficie de interoperabilidad puede afinarse entre versiones y conviene no construir sobre ella capas de abstracción que resulten caras de migrar. Conviene leer bien el alcance de esa etiqueta, porque no cubre lo mismo que la intuición sugiere: lo que puede moverse son las anotaciones y las reglas de la frontera, no la corrección del código generado ni el modelo de memoria, que descansa en una especificación cerrada de la plataforma.

Las herramientas, en cambio, dieron el salto más visible. Kotlin 2.4 estabilizó la compilación incremental de Kotlin/Wasm y la activó por defecto, con lo que el compilador reconstruye solo los ficheros afectados por cada cambio en lugar del módulo entero. El ciclo de edición y recarga, que era la queja dominante de quien probaba el objetivo, deja de ser el argumento en contra. Esa función llegó de forma experimental en la serie 2.1 y tardó tres ciclos en madurar, lo cual da una medida razonable de a qué ritmo avanza el objetivo.

Sobre el rendimiento circula mucha cifra sin contexto y conviene manejarla con cuidado. Las mediciones que publica el propio equipo del lenguaje sitúan la ejecución de Compose Multiplatform sobre Wasm claramente por encima de la del mismo código emitido a JavaScript y acercándose a la de la máquina virtual de Java, pero son mediciones de cargas dominadas por el dibujado y el cálculo, que es precisamente donde un binario verificado y sin recolector propio tiene ventaja. Extrapolarlas a una aplicación que pasa el noventa por ciento de su tiempo esperando respuestas de red y manipulando el documento es un error de método.

Los dos objetivos y cómo se declaran

Falta una distinción de nomenclatura que ordena el resto del nivel. Kotlin ofrece dos objetivos de WebAssembly que comparten compilador y difieren en el entorno anfitrión. El objetivo wasmJs produce un módulo pensado para ejecutarse dentro de un motor de JavaScript, con acceso al modelo de documento y a las API del navegador a través de la interoperabilidad; es el camino de Compose Multiplatform en la web. El objetivo wasmWasi produce un módulo para máquinas virtuales autónomas fuera del navegador, que obtienen sus capacidades de sistema mediante la interfaz estandarizada WASI; apunta a servicios y componentes de lado servidor, y se apoya en el modelo de componentes que WASI introdujo en su versión 0.2.

kotlin {
    wasmJs {
        binaries.executable()
        browser()
        generateTypeScriptDefinitions()
    }

    sourceSets["wasmJsMain"].dependencies {
        implementation("org.jetbrains.kotlinx:kotlinx-browser:0.3")
    }
}

El objetivo autónomo se declara igual de brevemente y produce un binario que no depende de ningún motor de JavaScript.

kotlin {
    wasmWasi {
        binaries.executable()
        nodejs()
    }
}

La biblioteca de navegador es una dependencia aparte y no parte de la biblioteca estándar, decisión que también se explica por el tamaño: las declaraciones del modelo de documento, de los vectores tipados y de las API gráficas son muchas y no todo módulo las necesita. Un módulo de lógica pura que solo exporte funciones no tiene por qué arrastrarlas.

Desde el otro lado, lo publicado se consume como un módulo ES corriente, y las funciones exportadas aparecen como propiedades del objeto exportado por defecto.

import exports from './modulo.mjs';
console.log(exports.calcularRuta(origen, destino));
📝
Un solo compilador, dos entornos, dos bibliotecas disponibles

Lo que cambia entre wasmJs y wasmWasi no es el generador de código sino qué API existe alrededor. Bajo WASI no hay documento, ni ventana, ni almacenamiento del navegador; hay ficheros, reloj, variables de entorno y flujos estándar, expuestos con la disciplina de capacidades explícitas que caracteriza a esa interfaz. Todo código que quiera servir a ambos entornos tiene que vivir en el conjunto de fuentes común y no tocar nada de lo específico, exactamente igual que en cualquier otro reparto multiplataforma.

WasmGC no amplió una plataforma: redefinió quién es responsable de la gestión de memoria, y esa reasignación es la que abre la puerta a los lenguajes gestionados

Merece la pena detenerse en la naturaleza exacta del cambio, porque la lectura superficial es que WebAssembly ganó una funcionalidad y la lectura correcta es que WebAssembly movió una frontera de responsabilidad. El diseño original expresaba una tesis coherente y defendible: la plataforma debía ser un sustrato mínimo y neutral, sin opinión sobre cómo se organiza la memoria, para no privilegiar a ningún lenguaje. La consecuencia no buscada de esa neutralidad fue que privilegió masivamente a los lenguajes que ya gestionaban la memoria a mano, porque para ellos el coste de adaptación era cero mientras que para los demás era el de portar un subsistema entero. Una plataforma neutral respecto de las abstracciones no es neutral respecto de los lenguajes: favorece a aquellos cuyas abstracciones ya coinciden con las suyas. WasmGC acepta ese diagnóstico y hace algo que las plataformas raras veces se permiten, que es incorporar al núcleo una abstracción de alto nivel a sabiendas de que deja de ser mínima. A cambio obtiene tres propiedades que no eran alcanzables de otro modo: la eliminación de un coste fijo que era prohibitivo para módulos pequeños, la unificación de dos grafos de alcanzabilidad que antes no podían razonar el uno sobre el otro, y la transferencia al motor de un trabajo que este ya hacía mejor porque ve la máquina real. El precio se paga en fragmentación temporal, y es el que explica la anomalía que esta lección insiste en señalar: el binario de Kotlin no depende de que el navegador ejecute WebAssembly sino de que ejecute una versión concreta y reciente de sus propuestas. La lección general es que la compatibilidad de una plataforma extensible nunca es un booleano, y que la pregunta útil no es si el entorno soporta la tecnología sino qué conjunto exacto de propuestas exige el artefacto que vamos a publicar.

⚔️ Mide el impuesto que WasmGC eliminó
  1. Compila un programa mínimo con wasmJs en modo de producción y anota el tamaño del binario y el del fichero cargador. Contrasta esa cifra con lo que ocuparía un recolector compilado.
  2. Enumera las propuestas de WebAssembly que tu artefacto necesita y traduce cada una a una versión mínima de navegador. Escribe la frase exacta que pondrías en la documentación de tu producto.
  3. Desactiva la compilación incremental en las propiedades del proyecto, mide un cambio de una línea y vuelve a activarla. Cuantifica la diferencia.
  4. Explica con un ejemplo concreto por qué un ciclo de referencias entre un objeto Kotlin y un objeto JavaScript era una fuga antes de WasmGC y deja de serlo después.
  5. Justifica ante un equipo escéptico por qué wasmWasi y wasmJs no son el mismo objetivo con dos nombres, citando qué API deja de estar disponible en cada caso.