Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web
|
This content was generated with the assistance of AI and should be verified against the official Kotlin Multiplatform documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
The browser is the one platform every user already has installed, and Kotlin Multiplatform (KMP) reaches it in two
different ways. Kotlin/Wasm compiles Kotlin to WebAssembly and, together with Compose Multiplatform (CMP), lets you run
the very same sharedUI module in a browser tab. Kotlin/JS compiles Kotlin to JavaScript and is the better choice
when Kotlin is only the shared logic behind a hand-written TypeScript front end. This page explains both paths, how they
share a source set, how to talk to JavaScript, how to debug them and how to deploy the result. Compiling Rust to
WebAssembly instead is covered in the Rust Reference’s WebAssembly.
Stability matters here more than on any other page of this section. Kotlin/Wasm for the web is Beta (since Kotlin 2.2.20), and Compose Multiplatform for web is Beta (since CMP 1.9). Kotlin/JS is Stable. Both Beta targets are suitable for experiments, internal tools and demos, but you should expect API and tooling changes, and you should test on the browsers your users actually run before promising a production launch. Where an individual API is Experimental or Alpha, the text says so.
The examples target the section baseline: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4 (through the
com.android.kotlin.multiplatform.library plugin in shared modules) and Xcode 26.4 on the Apple side. Libraries used
by neighbouring pages are Ktor 3.6.0, Koin 4.2.2, SKIE 0.10.15, KMP-NativeCoroutines 1.0.6, SQLDelight 2.4.0, Room 3
(androidx.room3 3.0.3), Mokkery 3.5.0, vanniktech 0.37.0, Dokka 2.2.0 and Turbine 1.2.1. Swift export and SwiftPM
import are Alpha. The examples are slices of TrailMate Everywhere (package com.example.trailmate), introduced in
What Kotlin Multiplatform Is and When to Use It, where
the webApp module is the wasmJs application. Kotlin itself is not re-taught; see
Kotlin: Build and Tooling. Compose basics are linked, not
repeated: Compose Multiplatform Fundamentals.
The Three Web Targets
KMP offers three targets that can run in or near a browser. They differ in what they compile to, where they run and how mature they are.
| Target | Compiles to and runs on | Typical use | Stability |
|---|---|---|---|
|
JavaScript; browsers and Node.js |
Shared logic consumed from TypeScript or JavaScript; Kotlin/JS-only web apps; npm libraries |
Stable |
|
WebAssembly with the WasmGC proposal plus a JavaScript loader; browsers (and Node.js for tests) |
Compose Multiplatform web apps; performance-sensitive logic; sharing the full UI |
Beta |
|
WebAssembly System Interface; WASI runtimes such as Node.js or Wasmtime, not the browser |
Server-side or edge Wasm modules without a JavaScript host |
Alpha |
The decision is mostly about the front end. If a web team already owns a React, Vue or Angular application and you want
to give it your domain code, choose js with TypeScript definitions. If you want one Compose UI on every screen, choose
wasmJs. If you need both, and many teams do, a project can declare js and wasmJs together and share most of the
code between them, as the next section shows. wasmWasi is mentioned for completeness: it targets WASI hosts and has
no DOM, so TrailMate does not use it. See
Kotlin docs — Get started with Kotlin/Wasm and WASI.
Why WasmGC Sets a Browser Floor
Kotlin/Wasm relies on the WebAssembly garbage collection proposal (WasmGC) so that Kotlin objects live in the engine’s own managed heap instead of shipping a Kotlin garbage collector inside the module. That keeps binaries small and fast, but it means the browser must implement WasmGC and the related exception-handling feature. Current Chromium-based browsers (Chrome and Edge 119 and later), Firefox 120 and later, and Safari 18.2 and later do; older browsers cannot instantiate the module at all. This is the reason the compatibility build exists (see below): it ships a JavaScript version for browsers without WasmGC.
| Concern | wasmJs |
js |
|---|---|---|
Browser requirement |
WasmGC-capable browser, or a JavaScript fallback build |
Any browser that runs modern JavaScript |
Start-up cost |
Download and compile a |
Download and parse JavaScript; no compile step |
Debugging |
Source maps and DevTools custom formatters, still maturing |
Mature source maps, familiar tooling |
JavaScript interop |
Typed through |
Direct, with |
Compose Multiplatform UI |
Supported (Beta) |
Not supported; use Compose HTML or a TypeScript UI |
The webMain Source Set
Since Kotlin 2.2.20 the default hierarchy template creates a shared source set named webMain (and webTest) as soon as
a module declares both js and wasmJs. It sits between commonMain and the two web leaves, exactly like iosMain
sits above the iOS targets, and it is where browser-only code that works on both targets belongs.
(browser APIs: window, document, fetch)"] W --> JS["jsMain"] W --> WA["wasmJsMain"] C --> A["androidMain"] C --> I["iosMain"] C --> D["jvmMain (desktop)"]
Code in webMain can use the Kotlin standard library’s browser API (kotlinx.browser.window, document,
localStorage) that is shared by both web targets. Code that must differ, such as raw interop that uses dynamic on
js but typed JsAny on wasmJs, goes into the leaf source sets through expect/actual (see
Expect/Actual and Platform APIs and
Project Structure and Source Sets).
Configuring the webApp Module
TrailMate’s webApp is an application module, not a shared library, so it applies the Compose plugins and produces an
executable. Only the wasmJs target is declared here; adding js as a second target is a one-line change if you also
want the fallback or a TypeScript consumer.
// webApp/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.kotlinMultiplatform) // org.jetbrains.kotlin.multiplatform
alias(libs.plugins.composeMultiplatform) // org.jetbrains.compose
alias(libs.plugins.composeCompiler) // org.jetbrains.kotlin.plugin.compose
}
kotlin {
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
outputModuleName = "trailmate"
browser {
commonWebpackConfig {
outputFileName = "trailmate.js"
}
}
binaries.executable()
}
sourceSets {
commonMain.dependencies {
implementation(project(":sharedLogic"))
implementation(project(":sharedUI"))
implementation(libs.compose.runtime)
implementation(libs.compose.ui)
}
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
}
}
}
Three details deserve explanation. ExperimentalWasmDsl is an opt-in because the Wasm Gradle DSL is still Beta and may
change. binaries.executable() asks for a runnable bundle rather than a library; without it the build produces only the
compiled klib and no .js or .wasm to serve. And browser { } selects the browser as the execution environment
(the alternative, nodejs(), is used for running tests outside a browser). The shared modules sharedLogic and
sharedUI must also declare wasmJs() in their kotlin { } block, otherwise Gradle cannot resolve them for this
target; see
Gradle Configuration and Compatibility.
Compose for Web with ComposeViewport
On Android the entry point is an Activity, on iOS a UIViewController and on desktop a Window. On the web the entry
point is a DOM element: ComposeViewport attaches a Compose canvas to an element of your index.html and renders
the composable tree into it. The Compose UI is drawn by Skia (through Skiko, compiled to WebAssembly) onto a <canvas>,
which is why the app looks identical on every browser but is not made of real HTML elements.
// webApp/src/wasmJsMain/kotlin/com/example/trailmate/web/Main.kt
package com.example.trailmate.web
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import com.example.trailmate.ui.App
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(viewportContainerId = "composeApplication") {
App()
}
}
ComposeViewport is Beta together with the web target and is annotated ExperimentalComposeUiApi in current
releases. Called without an id it fills the whole <body>, which is the simplest option and the one the project
wizard generates. Passing viewportContainerId lets the Compose canvas live inside a part of a larger HTML page, for
example a card on a documentation site.
<!-- webApp/src/wasmJsMain/resources/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TrailMate</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<script type="application/javascript" src="trailmate.js"></script>
</head>
<body>
<div id="composeApplication" style="width: 100vw; height: 100vh;"></div>
</body>
</html>
The container needs an explicit size: a <div> with no height collapses to zero and the app appears blank, which is
one of the most common first-run mistakes. Everything else about the UI, such as theming, layouts and state, is
ordinary Compose that you wrote once in sharedUI; the web-specific code is the entry point and the HTML around it.
Styling the Host Page with CSS
Compose draws its own pixels, so CSS cannot restyle a Compose button. CSS controls the page around the canvas: the
background shown before the first frame, the loading indicator, the page margin and the colour scheme that matches
TrailMateTheme. A small stylesheet prevents a white flash and removes the default body margin that would otherwise
create scrollbars around a full-viewport canvas.
/* webApp/src/wasmJsMain/resources/styles.css */
html, body {
margin: 0;
padding: 0;
height: 100%;
background-color: #1b3a2f; /* matches the TrailMate splash colour */
overflow: hidden;
}
#composeApplication {
position: fixed;
inset: 0;
}
.loading {
color: #f3f0e6;
font-family: system-ui, sans-serif;
text-align: center;
padding-top: 40vh;
}
Compatibility Build with a JavaScript Fallback
A browser without WasmGC would show a blank page. The Compose Multiplatform Gradle plugin therefore offers a second
distribution task that produces both a WebAssembly build and a plain JavaScript build, plus a small loader that
picks the right one at start-up. To use it the web module declares the js target next to wasmJs; the shared modules
must declare both too.
// webApp/build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
kotlin {
js {
browser()
binaries.executable()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
sourceSets {
// Code shared by both web targets, including ComposeViewport and browser APIs
webMain.dependencies {
implementation(libs.ktor.client.js)
}
}
}
With both targets declared, the distribution tasks are:
| Gradle task | Output | When to use it |
|---|---|---|
|
|
Wasm only; smallest and fastest, modern browsers only |
|
|
JavaScript only; widest compatibility, larger and slower |
|
One folder containing both builds and a loader that chooses at run time |
Public sites that must work on older browsers and Safari versions without WasmGC |
The trade-off is size and build time: the compatibility build compiles the whole application twice and the user
downloads only one of the two, but you maintain a single deployment. For an internal tool on managed browsers,
wasmJsBrowserDistribution alone is enough. The same task names exist for development servers
(wasmJsBrowserDevelopmentRun, jsBrowserDevelopmentRun), which start a webpack dev server with hot reload of the
compiled output.
Embedding Native HTML with HtmlElementView
Because the Compose UI is a canvas, it cannot show a video player, an <iframe>, a map widget or a rich-text editor
natively. HtmlElementView is Compose’s escape hatch on the web: it places a real DOM element on top of a rectangle of
the canvas and keeps it positioned and clipped with the composable. It is the web counterpart of UIKitView on iOS
(see Compose Multiplatform on iOS) and of SwingPanel on
desktop. The API is Experimental at the time of writing.
// sharedUI/src/wasmJsMain/kotlin/com/example/trailmate/ui/map/WebTrailMap.kt
package com.example.trailmate.ui.map
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.viewinterop.HtmlElementView
import kotlinx.browser.document
import org.w3c.dom.HTMLIFrameElement
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun WebTrailMap(embedUrl: String, modifier: Modifier = Modifier) {
HtmlElementView(
factory = {
(document.createElement("iframe") as HTMLIFrameElement).apply {
src = embedUrl
style.border = "none"
setAttribute("title", "Trail map")
}
},
modifier = modifier,
update = { iframe -> iframe.src = embedUrl },
)
}
The factory creates the element once, and update runs on recomposition so a changed embedUrl reaches the DOM.
Keep such views few and small. Each one is a separate compositing layer, it does not take part in Compose’s
semantics tree automatically (set title or ARIA attributes yourself), and it draws above or below the canvas rather
than blending into it, so Compose popups cannot overlap it. The shared code reaches it through an expect composable
TrailMap, so Android uses the Google Maps Compose library, iOS an MKMapView and the web this iframe.
Resources, Fonts and Loading
CMP’s composeResources (images, strings, fonts) work on the web, and the generated Res class is the same one used on
other targets. The web adds two considerations. First, resources are fetched over HTTP at run time, so the first
painterResource or stringResource call can appear after a short delay; preloading with the resource APIs hides it.
Second, the browser has no system font fallback inside a canvas: if the app displays emoji or non-Latin scripts you must
bundle a font that covers them in composeResources/font, or those glyphs render as empty boxes. See
Resources and Localization in Compose Multiplatform
for qualifiers and localization, and
Kotlin docs — Compose Multiplatform for web: resources.
Browser Navigation
A web app lives behind a URL, and users expect the back button, bookmarks and deep links to work. A canvas app does not
change the URL by itself, so navigation must be connected to the browser history. With Navigation Compose, the
bindToBrowserNavigation() function (Experimental) links the NavController back stack to window.history, so
navigating pushes an entry and the browser’s back button pops it. The routes are the same type-safe @Serializable
destinations used on Android and iOS.
// sharedUI/src/webMain/kotlin/com/example/trailmate/ui/navigation/WebNavigation.kt
package com.example.trailmate.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import androidx.navigation.bindToBrowserNavigation
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun rememberBrowserNavController(): NavHostController {
val navController = rememberNavController()
// Mirror the back stack in the address bar: /trail/42 opens TrailDetailScreen
androidx.compose.runtime.LaunchedEffect(Unit) {
navController.bindToBrowserNavigation()
}
return navController
}
A deep link such as https://example.github.io/trailmate//trail/42 must be resolved at start-up from the current
location. The destination graph, NavHost and back-stack behaviour are covered in
Lifecycle, ViewModel and Navigation in
Compose Multiplatform, and the Android equivalent in Android: Navigation with
Compose. Navigation 3 does not yet have a built-in browser-history binding in CMP, so apps that use it map the back
stack to the URL themselves. A static host such as GitHub Pages returns 404 for unknown paths, so prefer hash-based
URLs (/trail/42) there, or add a 404.html that redirects to index.html.
JavaScript Interop from Wasm
Eventually the app needs a browser feature that Kotlin has no wrapper for: geolocation, the Clipboard API, a
JavaScript library. Kotlin/Wasm talks to JavaScript through typed declarations, which is stricter than Kotlin/JS’s
dynamic. There are three tools: external declarations for existing JavaScript objects and functions, js("…")
for short inline snippets, and @JsModule to import a JavaScript module.
// sharedLogic/src/wasmJsMain/kotlin/com/example/trailmate/location/WebLocationProvider.kt
package com.example.trailmate.location
import kotlin.js.JsAny
import kotlin.js.Promise
// A JavaScript object: Wasm only sees it as JsAny, so wrap the part you need
external interface GeoPosition : JsAny {
val coords: GeoCoords
}
external interface GeoCoords : JsAny {
val latitude: Double
val longitude: Double
}
// Calls a JavaScript snippet and returns a Promise that resolves with the position
private fun currentPositionPromise(): Promise<GeoPosition> = js(
"""
new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject))
"""
)
class WebLocationProvider : LocationProvider {
override suspend fun currentLocation(): Location {
val position = currentPositionPromise().await<GeoPosition>()
return Location(position.coords.latitude, position.coords.longitude)
}
}
This is the LocationProvider from the running example: Android and iOS implement it with their platform location
services (see Expect/Actual and Platform APIs) and the
web implements it with navigator.geolocation. Four rules explain the shape of the code. Types that cross the boundary
must be JsAny subtypes or a small set of primitives (Int, Double, Boolean, String); you cannot pass a Kotlin
Trail directly. external declarations contain no bodies and describe JavaScript, not Kotlin. A js("…") call must
be the whole body of a top-level function or property. And Promise.await() bridges to coroutines so the Wasm code stays
suspend. The browser will ask the user for permission on the first call, and the call fails on non-HTTPS origins
(except localhost).
Importing a JavaScript Module
When the function you need lives in a JavaScript module rather than on the global object, @JsModule imports it by
module name. The module must be available at run time, which is where npm dependencies come in.
// webApp/src/wasmJsMain/kotlin/com/example/trailmate/web/Geohash.kt
package com.example.trailmate.web
@JsModule("ngeohash")
external object NGeohash : kotlin.js.JsAny {
fun encode(latitude: Double, longitude: Double, precision: Int): String
}
fun geohashOf(latitude: Double, longitude: Double): String =
NGeohash.encode(latitude, longitude, 7)
npm Dependencies
The Gradle plugin downloads npm packages through its bundled Node.js and Yarn. Declare them in the source set of the
target that needs them with npm(name, version); the version should be exact for reproducible builds.
// webApp/build.gradle.kts
kotlin {
sourceSets {
wasmJsMain.dependencies {
implementation(npm("ngeohash", "0.6.3"))
}
}
}
Prefer a Kotlin Multiplatform library over an npm package whenever one exists (for example kotlinx-datetime instead of a
date library), because an npm dependency exists only on the web targets and forces an expect/actual for the other
platforms. See
Dependencies, klibs.io, cinterop and SwiftPM Import.
Kotlin/JS for a TypeScript Front End
Not every web project should render Compose. A company may have a React or Angular front end, an established design
system and SEO needs that a canvas cannot meet, and still want TrailMate’s Trail model, validation and repository logic
written once. Kotlin/JS (Stable) compiles sharedLogic into an ES module and, optionally, a TypeScript declaration
file, so TypeScript code can call it with full type checking.
// sharedLogic/build.gradle.kts
kotlin {
js {
browser()
binaries.library()
useEsModules()
generateTypeScriptDefinitions()
}
}
binaries.library() produces a library rather than an application, useEsModules() emits modern import/export
modules, and generateTypeScriptDefinitions() writes the .d.ts file next to the compiled JavaScript. Only
declarations annotated with @JsExport appear in the output; everything else is stripped or mangled, which is
deliberate so internal refactoring cannot break a JavaScript consumer.
// sharedLogic/src/jsMain/kotlin/com/example/trailmate/export/TrailMateJs.kt
package com.example.trailmate.export
import com.example.trailmate.domain.Trail
import com.example.trailmate.data.TrailRepository
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.promise
import kotlin.js.Promise
@JsExport
class TrailListFacade(private val repository: TrailRepository) {
private val scope = MainScope()
// suspend functions and Flow cannot be exported; expose a Promise instead
fun loadTrails(region: String): Promise<Array<TrailJs>> = scope.promise {
repository.trailsFor(region).map { it.toJs() }.toTypedArray()
}
}
@JsExport
data class TrailJs(val id: String, val name: String, val lengthKm: Double)
private fun Trail.toJs() = TrailJs(id, name, lengthKm)
The restrictions on @JsExport are what shape the facade. Exported signatures may use primitives, strings, arrays,
Promise, exported classes and interfaces, but not Kotlin collections (List, Map), suspend functions,
Flow, sealed classes or extension functions. That is why the example converts to TrailJs, returns an Array
and wraps the coroutine in promise { }. The same facade pattern appears on iOS with SKIE or KMP-NativeCoroutines, which
turn suspend and Flow into async and AsyncSequence; see
Coroutines and Flow from Swift. The generated
declaration then makes the Kotlin API feel native in TypeScript.
// trailmate-web/src/trails.ts
import { TrailListFacade, TrailJs } from "trailmate-shared-logic";
const facade: TrailListFacade = createFacade(); // wiring omitted
export async function showTrails(region: string): Promise<void> {
const trails: TrailJs[] = await facade.loadTrails(region);
for (const trail of trails) {
console.log(`${trail.name} (${trail.lengthKm} km)`);
}
}
To publish the module to a TypeScript project, either consume build/dist/js/productionLibrary as a local package or
publish it to npm; a Gradle plugin for npm publishing is described in the Kotlin/JS documentation. For the opposite
direction, calling JavaScript from Kotlin/JS, use external declarations or dynamic. See also
React Native, which solves the same "shared logic behind a JavaScript UI" problem
with a different runtime.
Choosing Between the Web Options
| Question | Compose on wasmJs |
Kotlin/JS with a TypeScript UI | Both targets |
|---|---|---|---|
Share UI code with Android, iOS and desktop |
Yes |
No, only logic |
Yes, with a JavaScript fallback |
Real HTML, SEO and accessibility tree |
No, canvas based; limited semantics |
Yes |
Depends on the UI chosen |
Stability |
Beta |
Stable |
Beta for the Wasm part |
Team skills needed |
Kotlin and Compose only |
Kotlin and TypeScript |
Kotlin; Gradle for two targets |
Bundle size |
Larger (Skiko and Compose runtime) |
Small to medium |
Largest build, one download per user |
Best for TrailMate when |
An internal dashboard or demo reuses the full app |
A marketing site needs the Trail model and search logic |
A public app must work on old browsers |
Debugging in the Browser
Browser DevTools can debug Kotlin directly through source maps, which the Gradle plugin generates for development
builds. Run wasmJsBrowserDevelopmentRun (or jsBrowserDevelopmentRun), open the page, then open DevTools and find your
Kotlin files under the Sources panel, where breakpoints and stepping work. For Wasm, enable the DevTools custom
formatters setting so Kotlin objects display as Kotlin values rather than opaque WasmGC references. Chromium-based
browsers have the most complete Wasm debugging; Firefox and Safari can step through code but show less detail.
// webApp/src/wasmJsMain/kotlin/com/example/trailmate/web/Diagnostics.kt
package com.example.trailmate.web
import kotlinx.browser.window
fun logStartup(version: String) {
// Appears in the DevTools console
println("TrailMate web $version starting on ${window.navigator.userAgent}")
// Pause here when DevTools is open
js("debugger")
}
Three habits save time. Read the browser console first: an unhandled Kotlin exception appears there with a Kotlin stack
trace. Check the Network tab for the .wasm file: a wrong Content-Type (it must be application/wasm) or a failed
compressed download prevents instantiation. And test the production build occasionally, because dead-code elimination
and optimisation (Binaryen) can expose bugs that the development build hides.
Build and Deploy to GitHub Pages
A web app is a folder of static files, so any static host works. Build the production distribution, then upload the
folder. For TrailMate the demo is hosted on GitHub Pages with a workflow that builds on every push to main.
# Build only the Wasm distribution
./gradlew :webApp:wasmJsBrowserDistribution
# Or, to support browsers without WasmGC, build both and the loader
./gradlew :webApp:composeCompatibilityBrowserDistribution
# Preview the result locally with any static server
npx serve webApp/build/dist/wasmJs/productionExecutable
# .github/workflows/deploy-web.yml
name: Deploy web demo
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
- uses: gradle/actions/setup-gradle@v6
- run: ./gradlew :webApp:wasmJsBrowserDistribution
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: webApp/build/dist/wasmJs/productionExecutable
- id: deployment
uses: actions/deploy-pages@v4
GitHub Pages serves the app from a sub-path (/trailmate/), so keep asset and script references relative, as the
index.html above does, rather than starting them with a slash. GitHub Pages does not let you set response headers, so
it cannot send Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy; the Kotlin/Wasm app does not require them,
but libraries that need SharedArrayBuffer do. Hosts like Netlify, Cloudflare Pages or your own server can set them.
Large downloads benefit from gzip or Brotli compression enabled on the host. The general publishing and CI picture,
including iOS and Android, is in the section’s publishing pages; the sibling workflow for building the Kotlin code
itself is described in Getting Started and Tooling.
Platform Differences to Remember
| Area | Behaviour on the web | What to do |
|---|---|---|
Threads |
A single main thread; no shared-memory threads by default |
Use coroutines with |
File system |
None |
Use |
Network |
Browser |
Configure the API’s CORS headers; use the Ktor |
Text input |
Compose draws the field; the browser supplies an invisible input for IME |
Test composition on mobile browsers |
Accessibility |
Limited semantics for canvas content |
Provide titles and labels on embedded HTML; test with a screen reader |
Window sizing |
The viewport can change at any time |
Use window size classes for adaptive layouts |
Common Mistakes
-
Presenting Wasm as Stable. Kotlin/Wasm and CMP for web are Beta; state that to stakeholders and plan a fallback.
-
Forgetting
binaries.executable(). The build succeeds but produces no runnable bundle to serve. -
Giving the container no size. A
<div>without height makes the canvas zero pixels tall and the page blank. -
Declaring
wasmJsonly in the app module. Shared modules must declare the target as well. -
Ignoring older browsers. Without WasmGC the page does not load; use the compatibility distribution if the audience is public.
-
Passing Kotlin collections across the JavaScript boundary. Use arrays,
JsAnytypes or exported classes. -
Exporting
suspendfunctions orFlowwith@JsExport. Wrap them inPromisefacades. -
Using absolute asset paths on GitHub Pages. The app is served from a sub-path; keep paths relative.
-
Relying on a font that is not bundled. Emoji and non-Latin text render as empty boxes inside the canvas.
-
Mutating the DOM behind Compose’s back. Use
HtmlElementViewfor embedded elements.