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

js

JavaScript; browsers and Node.js

Shared logic consumed from TypeScript or JavaScript; Kotlin/JS-only web apps; npm libraries

Stable

wasmJs

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

wasmWasi

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 .wasm module; fast steady-state

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 JsAny and external; no dynamic types

Direct, with dynamic and external

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.

flowchart TB C["commonMain"] --> W["webMain
(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

wasmJsBrowserDistribution

webApp/build/dist/wasmJs/productionExecutable

Wasm only; smallest and fastest, modern browsers only

jsBrowserDistribution

webApp/build/dist/js/productionExecutable

JavaScript only; widest compatibility, larger and slower

composeCompatibilityBrowserDistribution

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 Dispatchers.Default, which runs on the main thread; avoid blocking calls

File system

None

Use localStorage, IndexedDB or a server; see Local Storage

Network

Browser fetch, subject to CORS

Configure the API’s CORS headers; use the Ktor Js engine

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 wasmJs only 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, JsAny types or exported classes.

  • Exporting suspend functions or Flow with @JsExport. Wrap them in Promise facades.

  • 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 HtmlElementView for embedded elements.