Running Kotlin in the Browser with Kotlin/Wasm

This page answers one task: a team with Kotlin code — shared business logic, a Compose Multiplatform UI, or a Kotlin-first developer group — wants it running in the browser as WebAssembly, and needs to know what the Kotlin/Wasm target requires and how it talks to JavaScript.

Prerequisites

  • [ ] Kotlin 2.x with Gradle and the Kotlin Multiplatform plugin.
  • [ ] Browsers with WebAssembly garbage collection (WasmGC) and exception handling: current Chrome, Edge, Firefox and Safari 18.2+.
  • [ ] Familiarity with Kotlin Multiplatform project structure.

What Kotlin/Wasm is

Kotlin has long compiled to JavaScript (Kotlin/JS). Kotlin/Wasm is a newer target that compiles Kotlin to WebAssembly using the WasmGC proposal. Instead of shipping its own garbage collector inside linear memory — as C#, Go and older managed-language ports do — it allocates Kotlin objects as WasmGC structs and arrays, managed by the browser’s own garbage collector. That makes binaries smaller, avoids duplicating a GC, and lets the engine optimise object access. It also means Kotlin/Wasm requires WasmGC support: browsers without it cannot run the output at all.

In exchange, Kotlin/Wasm typically runs computation-heavy code faster than Kotlin/JS, and it is the foundation of Compose Multiplatform for the web, which renders Compose UIs to a canvas. The target is evolving quickly; check the Kotlin release notes for the current status of APIs and tooling before committing a production app to it.

Kotlin/JS versus Kotlin/Wasm Kotlin/JS compiles to JavaScript, runs in every browser and interoperates naturally with npm. Kotlin/Wasm compiles to WasmGC, needs recent browsers, and usually runs computation faster, with the browser's garbage collector managing Kotlin objects. Kotlin/JS compiles to JavaScript runs in every browser natural npm interop broad compatibility Kotlin/Wasm compiles to WasmGC needs GC + EH support faster compute, Compose web modern browsers

Step 1 — add the wasmJs target

In a Kotlin Multiplatform module’s build.gradle.kts:

kotlin {
    @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
    wasmJs {
        browser {
            commonWebpackConfig { outputFileName = "app.js" }
        }
        binaries.executable()
    }
    sourceSets {
        commonMain.dependencies { /* shared Kotlin libraries with wasmJs support */ }
    }
}

./gradlew wasmJsBrowserDevelopmentRun starts a development server; ./gradlew wasmJsBrowserDistribution produces a production bundle in build/dist/wasmJs/productionExecutable/ containing the .wasm file, a JavaScript loader and resources. Libraries must support the wasmJs target, and the Gradle sync reports unresolved variants clearly when one does not — kotlinx.coroutines, kotlinx.serialization and Ktor client do; check others before depending on them.

Step 2 — call JavaScript and the DOM

Kotlin/Wasm provides kotlinx.browser for common browser objects, and external declarations for anything else. Inline JavaScript can be embedded with js("…") in top-level functions:

import kotlinx.browser.document
import kotlinx.browser.window

external fun alert(message: String)

fun now(): Double = js("performance.now()")

fun main() {
    val button = document.getElementById("go") ?: return
    button.addEventListener("click", { _ ->
        val t = now()
        document.getElementById("out")?.textContent = "Clicked at ${t.toInt()} ms"
    })
}

Strings and primitive types cross the boundary automatically; JavaScript objects are represented as JsAny and its subtypes. Each interop call has a cost, so batch DOM updates rather than touching the DOM in tight loops.

Step 3 — export Kotlin functions to JavaScript

To use Kotlin/Wasm as a library from a JavaScript app, mark functions with @JsExport:

@OptIn(ExperimentalJsExport::class)
@JsExport
fun slugify(input: String, maxLength: Int = 60): String =
    input.lowercase().split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.joinToString("-").take(maxLength)

The generated loader module exports slugify, callable after instantiation. Exported signatures are restricted to types that can cross the boundary — primitives, strings, JsAny subtypes and external interfaces — so design a small facade rather than exporting your domain model directly. A facade also gives you one place to version the API that JavaScript callers depend on.

A Kotlin/Wasm app in the browser Kotlin code compiles to a WasmGC module whose objects live on the browser's garbage-collected heap rather than in linear memory. A generated JavaScript loader instantiates it and provides imports for DOM and browser APIs, and exported functions are callable from JavaScript. Kotlin code common + wasmJs source sets Kotlin/Wasm compiler WasmGC structs and arrays app.wasm + loader JS imports for DOM, exports via @JsExport browser engine GC heap shared with JavaScript requirements WasmGC + exception handling

Step 4 — build UIs with Compose Multiplatform

Compose Multiplatform supports the web through Kotlin/Wasm: the same composables used on Android and desktop render to a <canvas> via Skia (Skiko) in the browser. The Gradle template wires it up with ComposeViewport(document.body!!) { App() }. This gives pixel-consistent UIs across platforms at the cost of a larger download (the Skia renderer adds several megabytes) and canvas-rendered text, which is less accessible and not selectable by default than HTML text. It suits app-like tools; content sites are better served by HTML.

Interop with npm packages

Kotlin/Wasm can call into npm packages through external declarations with @JsModule("package-name"), which the generated webpack configuration resolves from node_modules. Add the package with Gradle’s npm("name", "version") dependency so the build installs it. Keep such bindings thin — a handful of functions with simple types — because every call converts arguments at the boundary, and complex JavaScript objects arrive as opaque JsAny values that must be inspected through further declarations. For heavy JavaScript libraries, it is often simpler to keep that part of the app in JavaScript and call Kotlin only for the logic it owns.

Step 5 — check size, startup and browser support

A small Kotlin/Wasm logic module without Compose is typically a few hundred kilobytes after optimisation; with Compose it reaches several megabytes. Enable the production build’s optimisations (the distribution task runs Binaryen), serve with Brotli, and cache aggressively. Detect WasmGC support before loading, and fall back to a Kotlin/JS build or an explanatory message where it is missing:

import { gc, exceptions } from "wasm-feature-detect";

const useWasm = (await gc()) && (await exceptions());
await import(useWasm ? "./wasm/app.js" : "./js/app.js");       // Kotlin/Wasm or Kotlin/JS build

Feature detection is covered in detecting proposal support at runtime.

Debugging and testing

Kotlin/Wasm builds emit source maps, and Chrome DevTools can step through Kotlin source, set breakpoints and inspect variables in development builds; because objects are WasmGC structs, DevTools shows them with their fields rather than as raw memory, which is a pleasant difference from linear-memory languages. Firefox and Safari support is improving. Unit tests in commonTest run on the wasmJs target with ./gradlew wasmJsTest, which launches a headless browser (or Node, with the nodejs environment) and reports results through Gradle, so the same tests that cover JVM and Android code also cover the Wasm build. Run them in CI on every change: differences between platforms — floating-point formatting, regular expression flavours, default locale behaviour — tend to surface as test failures on one target only, and catching them early is the main defence. For performance, profile with the browser’s Performance panel; Kotlin function names appear in profiles when the name section is kept.

Sharing logic across platforms

The most immediate value of Kotlin/Wasm for many teams is sharing code that already exists: validation rules, pricing calculations, parsers, domain models used by an Android app and a Kotlin backend. Put that code in commonMain, add the wasmJs target, and the browser runs the exact same rules, which removes a whole class of “the app and the website disagree” bugs. Keep platform-specific code — storage, networking, UI — behind expect/actual declarations or interfaces, and keep the exported surface small and stable. For teams that are not ready to adopt the WasmGC requirement, the same shared module can also target Kotlin/JS, and the web app can choose which build to load based on feature detection. Compare both builds’ size and speed for your code; for logic-heavy modules Kotlin/Wasm usually wins on speed, while Kotlin/JS can be smaller for code that mostly manipulates JavaScript objects.

Expected output

./gradlew wasmJsBrowserDistribution produces a production bundle whose slugify export runs in current Chrome, Firefox and Safari; the DOM example updates text on click; and older browsers without WasmGC receive the fallback.

Gotchas

  • Browsers without WasmGC. The module fails to compile. Detect support and fall back.
  • Libraries without wasmJs support. Check every dependency’s targets before adopting the target.
  • Chatty DOM interop. Each call crosses the boundary. Batch updates.
  • Exporting complex Kotlin types. Only boundary-compatible types can be exported. Build a facade.
  • Forgetting the npm dependency in Gradle. @JsModule bindings fail at bundle time. Declare npm(...) dependencies.
  • Assuming Compose web is like HTML. It renders to canvas; plan for accessibility and text selection.

Performance note

For a JSON-heavy validation benchmark, Kotlin/Wasm ran about 2.4× faster than Kotlin/JS in Chrome. The logic-only module was 340 KB raw (110 KB Brotli); a minimal Compose app was 7.8 MB raw (2.6 MB Brotli), dominated by the Skia renderer.

Validation benchmark, Kotlin/JS versus Kotlin/Wasm Milliseconds to validate ten thousand records with the same Kotlin code compiled to JavaScript and to WasmGC, in Chrome on a laptop. ms per run Kotlin/JS 182 ms Kotlin/Wasm 76 ms

Frequently Asked Questions

Is Kotlin/Wasm production-ready? It has moved from experimental towards beta and stable status for some uses; check the current Kotlin documentation and test on your target browsers.

Can Kotlin/Wasm run outside the browser? A wasmWasi target exists for WASI runtimes that support WasmGC, though server-side runtime support is less mature.

How do coroutines work? kotlinx.coroutines supports wasmJs; suspending functions integrate with JavaScript Promises through interop helpers.

Does it support Kotlin reflection? Only limited reflection is available on Wasm, similar to Kotlin/JS.

What happens on a browser that lacks exception handling but has GC? Current Kotlin/Wasm output needs both features; detect both, as shown above, and fall back when either is missing.

Can Kotlin/Wasm and Kotlin/JS share one npm package? Yes — publish both builds and choose at runtime, with the shared API documented once.

← Back to Other Languages in the Browser