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.
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.
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.
@JsModulebindings fail at bundle time. Declarenpm(...)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.
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.
Related
- Using Wasm GC for managed languages — the proposal Kotlin/Wasm builds on.
- Running .NET in the browser — a managed runtime with its own GC.
- Comparing payload size across languages — where Kotlin lands.
- Shipping a JavaScript fallback for a Wasm feature — the Kotlin/JS fallback.
← Back to Other Languages in the Browser