wandres.dev
WASM Y JS · los otros backends

Elegir backend: cuándo Wasm, cuándo JS, cuándo ninguno

Tener dos objetivos de web no es tener el doble de opciones sino tener que decidir, y la decisión no se toma comparando cifras de rendimiento sino identificando qué restricción domina en cada proyecto. Esta lección propone un criterio de elección basado en la intensidad de la interoperabilidad, la naturaleza de la interfaz y el suelo de navegadores exigible, revisa qué cabe esperar de cada backend en 2026 tras la estabilización de la compilación incremental de Wasm, y defiende que la tercera respuesta legítima es no usar ninguno.

⏱ 20 min

Cuando una plataforma ofrece dos caminos hacia el mismo destino, la pregunta interesante nunca es cuál es mejor sino qué restricción es la que manda en el caso concreto, porque los dos caminos existen precisamente porque ninguna respuesta única servía para todos. Kotlin llega a la web por dos rutas que comparten lenguaje, biblioteca estándar y modelo de concurrencia, y difieren en todo lo demás: una emite JavaScript y hereda la fluidez y la laxitud de ese mundo; la otra emite un binario verificado que ejecuta más rápido y habla con el exterior a través de una frontera estrecha y explícita. Elegir entre ambas comparando cifras de rendimiento es el error más común y el más caro, porque el rendimiento rara vez es la restricción dominante en un proyecto de web, mientras que la intensidad de la interoperabilidad, la naturaleza de la interfaz y el conjunto de navegadores que hay que cubrir lo son casi siempre. Y conviene decir desde el principio que hay una tercera respuesta legítima, que consiste en no llevar Kotlin al navegador en absoluto.

🎯 Al terminar esta lección sabrás
  • Aplicar tres criterios de decisión concretos para elegir entre Kotlin/Wasm, Kotlin/JS o ninguno.
  • Explicar qué significa que Kotlin/Wasm siga en Beta y qué piezas suyas ya son estables.
  • Valorar el efecto de la compilación incremental activada por defecto sobre el ciclo de desarrollo.
  • Reconocer los escenarios en los que compilar Kotlin a la web no es la solución correcta.

Los tres criterios que deciden

Antes de enunciarlos conviene descartar el criterio que casi todo el mundo aplica primero y que casi nunca decide nada: la velocidad de ejecución. En una aplicación de web típica, el tiempo se va en esperar respuestas de red, en recalcular disposición y en pintar; la fracción que ocupa el cómputo del lenguaje suele ser pequeña, y duplicar o triplicar su velocidad no mueve ninguna métrica que el usuario perciba. Solo cuando existe un núcleo de cálculo real en el cliente, como un motor de reglas, un cifrado, un procesamiento de imagen o el dibujado de una interfaz completa, ese criterio pasa a mandar. Los tres que siguen mandan casi siempre.

El primero y más determinante es la intensidad de la interoperabilidad. Si el módulo tiene que conversar de forma continua y fina con código JavaScript existente, pasando objetos de forma irregular, recibiendo callbacks y manipulando estructuras cuya forma solo se conoce en ejecución, el backend adecuado es JavaScript, y esa es además la recomendación explícita de quienes mantienen ambos. La razón es estructural y ya la conocemos: allí un objeto Kotlin es un objeto JavaScript, mientras que en WebAssembly cada cruce exige una traducción y muchos tipos ni siquiera cruzan.

El segundo es la naturaleza de la interfaz. Una interfaz construida a partir del modelo de documento, con marcado, hojas de estilo y componentes del ecosistema, vive de forma natural en JavaScript. Una interfaz declarativa que se dibuja sobre un lienzo y comparte código con las versiones de móvil y escritorio es el caso de uso para el que se construyó el objetivo de WebAssembly, y donde su ventaja de ejecución se nota de verdad: las mediciones publicadas sobre cargas dominadas por la interfaz sitúan a Wasm en el entorno del triple de velocidad frente al mismo código emitido a JavaScript.

Este criterio arrastra consecuencias que van más allá del rendimiento y que conviene poner sobre la mesa antes de decidir. Una interfaz dibujada sobre un lienzo no produce marcado, de modo que la accesibilidad, la selección de texto, la búsqueda dentro de la página y la indexación dejan de venir dadas por el navegador y pasan a ser responsabilidad del marco de trabajo. Compose Multiplatform aporta soluciones para lo esencial, pero el reparto de responsabilidades cambia y hay que saberlo de antemano.

El tercero es el suelo de navegadores. El binario de Kotlin exige un motor con recolección de basura de WebAssembly, disponible desde Chrome 119, Firefox 120 y Safari 18.2. Para un producto interno o para una audiencia que actualiza, esto es irrelevante; para un servicio público con obligación de cubrir dispositivos antiguos, es una restricción dura que decide sola. Nótese que el criterio no admite grados: o el motor implementa la propuesta o el módulo no arranca, sin degradación posible más allá de servir una aplicación alternativa.

Hay una cuarta consideración que no es un criterio sino un aviso: la elección no tiene por qué ser exclusiva ni definitiva. Un proyecto puede declarar ambos objetivos y compartir entre ellos todo lo que no toque la frontera, pagando el precio de que el código de interoperabilidad se escriba dos veces.

kotlin {
    js { browser(); binaries.executable() }
    wasmJs { browser(); binaries.executable() }

    sourceSets {
        // Logica pura, compartida por los dos objetivos
        val commonMain by getting
        // La frontera, escrita por separado en cada uno
        val jsMain by getting
        val wasmJsMain by getting
    }
}
ℹ️
Lo que se comparte y lo que no

Las corrutinas, la serialización, las colecciones y toda la lógica que no mencione un tipo de la plataforma compilan sin cambios a los dos objetivos. Lo que no se comparte es exactamente la capa de interoperabilidad, porque dynamic no existe en Wasm y JsAny con sus restricciones de tipo no tiene sentido en JavaScript. Si esa capa está bien aislada, mantener ambos objetivos cuesta poco; si está repartida por toda la base de código, mantener uno solo ya costaba demasiado.

flowchart TD
A[Necesito Kotlin en el navegador] --> B{Interop fina y continua con JavaScript}
B -- Si --> C[Kotlin JS]
B -- No --> D{La interfaz es Compose compartida}
D -- Si --> E{Puedo exigir navegadores con WasmGC}
E -- Si --> F[Kotlin Wasm]
E -- No --> C
D -- No --> G{Solo comparto logica de negocio}
G -- Si --> H[Modulo exportado, cualquiera de los dos]
G -- No --> I[Quiza no necesito ninguno]

Qué esperar de Kotlin/Wasm

La palabra Beta describe la superficie de interoperabilidad, no la fiabilidad del código generado. Lo que puede moverse entre versiones son los detalles de cómo se declara la frontera; lo que ya no se mueve es el modelo de memoria, que descansa en una propuesta cerrada de la plataforma, ni la corrección de la ejecución. La lectura operativa es que conviene concentrar toda la interoperabilidad en una capa fina y propia, para que una eventual migración toque pocos ficheros.

El cambio más relevante del último ciclo no es de lenguaje sino de herramientas. Kotlin 2.4 declaró estable la compilación incremental de Kotlin/Wasm y la activó por defecto, de modo que el compilador reconstruye únicamente los ficheros afectados por cada edición en lugar del módulo completo. La queja histórica sobre este objetivo era casi siempre la misma, que el ciclo de editar y ver el resultado no aguantaba la comparación con el del ecosistema de web, y esa queja deja de ser válida. Sigue existiendo un interruptor para desactivarla en las propiedades del proyecto, útil solo para aislar un problema de construcción y no para el trabajo diario.

Ese interruptor vive en las propiedades del proyecto y solo debería aparecer de forma temporal, mientras se aísla un fallo de construcción sospechoso de venir de la reconstrucción parcial.

// gradle.properties del proyecto
// kotlin.incremental.wasm=false

El detalle de por qué esto costó tanto ilumina la naturaleza del objetivo. La eliminación de código muerto que hace viable el tamaño de un binario de Wasm es un análisis de programa completo, y un análisis de programa completo es, por definición, lo contrario de un análisis incremental: cambiar una línea puede alterar la alcanzabilidad de cualquier otra. Compaginar ambas cosas exigió reconstruir la contabilidad de dependencias entre ficheros, y ese es el trabajo que la serie 2.x fue completando durante tres versiones seguidas hasta estabilizarlo.

Junto a eso conviene tener presente qué implica en la práctica adoptar un objetivo en Beta. No es un problema de fiabilidad sino de coste de mantenimiento: hay que leer las notas de versión, hay que estar dispuesto a ajustar la capa de interoperabilidad una o dos veces al año y hay que evitar que una biblioteca ajena se interponga entre el proyecto y esa capa, porque entonces el ritmo de actualización ya no lo marca el equipo.

💡
Lo incremental cambia el diagnóstico, no solo la espera

Un ciclo corto no es una comodidad: es lo que permite que la depuración avance por hipótesis pequeñas en lugar de por cambios grandes. Cuando cada iteración cuesta un minuto, el programador agrupa modificaciones y pierde la capacidad de atribuir un efecto a una causa. Al evaluar si un objetivo es apto para un equipo, el tiempo de reconstrucción pesa más que el tiempo de ejecución, porque el primero lo paga el desarrollador cientos de veces al día y el segundo lo paga el usuario una vez.

Fuera del navegador el objetivo tiene una segunda vida que conviene no pasar por alto. Con la interfaz de sistema de WebAssembly y el modelo de componentes se pueden construir servicios y complementos que se despliegan como binarios portátiles y aislados, en entornos donde la unidad de despliegue ya no es un contenedor sino un módulo. El atractivo aquí no es la velocidad sino el arranque instantáneo y el aislamiento por capacidades: un módulo no puede tocar nada que el anfitrión no le haya concedido explícitamente, lo que convierte la ejecución de código de terceros en un problema tratable. Es un territorio joven, pero es el que explica por qué este backend importa más allá de la web.

Qué esperar de Kotlin/JS

Es el objetivo maduro y el que mejor se integra. Su ventaja no está en ninguna cifra sino en la ausencia de fricción: cualquier paquete del ecosistema se consume describiéndolo, cualquier objeto se manipula con dynamic cuando no queda otra, el resultado se empaqueta con las herramientas que el equipo de web ya conoce y el módulo publicado con @JsExport es indistinguible de uno escrito allí.

// La interoperabilidad aqui no cuesta nada porque no hay traduccion:
// un objeto Kotlin ya es un objeto JavaScript
external fun observar(nodo: dynamic, opciones: dynamic): dynamic

fun montar(raiz: dynamic) {
    observar(raiz, js("({ childList: true, subtree: true })"))
}

Su punto débil es el que tiene todo JavaScript: la ejecución depende de la optimización especulativa del motor y la representación de los tipos numéricos no es la que Kotlin querría, con lo que el cálculo intensivo rinde peor y de forma menos predecible. Para la inmensa mayoría de las aplicaciones de web, eso no es la restricción que manda.

El caso de uso donde este objetivo brilla sin discusión es el del módulo compartido: una organización que ya tiene aplicaciones de móvil en Kotlin y quiere que la web use exactamente las mismas reglas de negocio, la misma validación y los mismos modelos de datos, sin reimplementarlos en otro lenguaje y sin que se desincronicen a la tercera semana.

@JsExport
class ValidadorDePedido(private val catalogo: Catalogo) {
    fun validar(lineas: Array<Linea>): Array<String> =
        lineas.flatMap { catalogo.problemasDe(it) }.toTypedArray()
}

Un módulo así se publica, se consume desde TypeScript con sus definiciones de tipos generadas y el equipo de web no necesita saber en qué lenguaje se escribió. Esa invisibilidad es el mejor indicador de que la integración se hizo bien.

🕸️

Elige `js`

Interoperabilidad intensa, interfaz basada en el documento, necesidad de cubrir navegadores antiguos o integración en una base de código de web ya existente.

Elige `wasmJs`

Interfaz de Compose compartida entre plataformas, cálculo intensivo en el cliente, audiencia con navegadores modernos.

🚪

Elige ninguno

Contenido con exigencias de indexación, presupuesto de descarga muy ajustado o un equipo de web sin ningún vínculo con Kotlin.

Cuándo ninguno

Hay tres situaciones en las que la respuesta correcta es no compilar Kotlin al navegador, y merecen decirse con la misma claridad que las otras dos.

La primera es la de los sitios cuyo valor está en el contenido y en su indexación. Ahí lo que se necesita es marcado servido desde el servidor, accesible y legible por un rastreador sin ejecutar nada, y cualquier arquitectura que genere la página en el cliente juega en contra del objetivo del producto. Una interfaz dibujada sobre un lienzo es el caso extremo: para un rastreador, y también para un lector de pantalla mal atendido, esa página está sencillamente vacía.

La segunda es la del presupuesto de descarga muy ajustado, típica de mercados con conectividad pobre o de módulos incrustados en páginas ajenas, donde cada kilobyte se negocia y una interfaz que arrastra su propio motor de dibujo no es defendible bajo ningún argumento de productividad.

La tercera es organizativa y es la más frecuente de todas. Si el equipo de web no tiene ninguna relación con Kotlin, imponerle un backend que no sabe depurar transfiere el coste de la elección a quien no la tomó, y ese coste se cobra en cada incidente de producción a las tres de la mañana. Casi siempre sale mejor exponer la lógica compartida como un servicio, o publicar un módulo pequeño y bien tipado que el equipo consuma como cualquier otra dependencia, que unificar el lenguaje por principio.

⚠️
El error de elegir por identidad y no por restricción

La tentación de escribir todo el sistema en un solo lenguaje es real y tiene argumentos legítimos a favor, pero se convierte en una trampa cuando sustituye al análisis. La pregunta que ordena la decisión no es qué lenguaje preferimos sino qué restricción nos va a doler primero, y esa restricción casi nunca es el lenguaje: es el suelo de navegadores, el presupuesto de descarga, la capacidad de depuración del equipo que mantendrá el sistema o la densidad de la conversación con código ajeno. Un criterio que no nombra ninguna de esas cuatro cosas no es un criterio.

La existencia de dos backends no es una transición inacabada hacia uno solo: es el reconocimiento de que la web tiene dos modelos de ejecución y de que la elección correcta depende de dónde se sitúe la frontera del sistema

Es tentador leer la coexistencia de Kotlin/JS y Kotlin/Wasm como una etapa provisional en la que el segundo acabará sustituyendo al primero cuando madure, y esa lectura conduce a decisiones malas porque prescribe esperar en lugar de elegir. Lo que en realidad ocurre es que la web ofrece dos modelos de ejecución con propiedades irreconciliables y que ninguno domina al otro en el sentido técnico del término. JavaScript es un entorno de objetos dinámicos con un modelo de propiedades abierto, y esa apertura es exactamente lo que hace barata la integración con cualquier cosa que ya viva allí; WebAssembly es un entorno de código verificado con un sistema de tipos cerrado, y esa clausura es exactamente lo que le da velocidad predecible y una superficie de ataque más pequeña. Se puede tener lo uno o lo otro, y pedir ambos a la vez es pedir que un sistema sea simultáneamente abierto y cerrado en la misma dimensión. Por eso el criterio que de verdad discrimina no es el rendimiento sino la posición de la frontera del sistema que se está construyendo: si la frontera es ancha y difusa, porque el módulo conversa sin parar con código ajeno, conviene el modelo abierto y el coste de esa apertura se paga en velocidad; si la frontera es estrecha y bien definida, porque el módulo recibe un encargo, calcula y devuelve un resultado, conviene el modelo cerrado y el coste de esa clausura se paga en incomodidad de integración, que en ese caso apenas se sufre porque casi no se cruza. Formulado así, el criterio deja de depender de qué versión de Kotlin esté instalada y pasa a depender de una propiedad arquitectónica que el ingeniero controla, que es cuánta comunicación ha decidido que atraviese el límite. Y de ahí sale también la respuesta a la tercera opción: cuando la frontera correcta del sistema resulta estar en el servidor y no en el navegador, la elección de backend era una pregunta mal planteada.

⚔️ Decide con criterio y déjalo por escrito
  1. Toma un proyecto real y estima cuántas travesías de la frontera hace por segundo en su camino más caliente. Usa ese número, y no una comparativa ajena, para elegir backend.
  2. Escribe la política de navegadores mínimos de tu producto y comprueba si es compatible con exigir recolección de basura de WebAssembly.
  3. Mide el ciclo de edición y recarga en ambos objetivos con la compilación incremental activa y decide cuál soportaría tu equipo a diario.
  4. Aísla toda la interoperabilidad de un módulo de Wasm en una única capa y cuenta cuántos ficheros tocarías si la superficie cambiara.
  5. Argumenta por escrito, para un caso concreto de tu organización, por qué la opción correcta es no llevar Kotlin al navegador.