Kotlin Compose Multiplatform runs one UI codebase on desktop (JVM), Android, iOS and the web (wasmJs). The first three targets are all processes on an operating system, and they differ from each other in degree. The web differs in kind. Code written for the other targets rests on assumptions that were never written down because they had always been true: that threads exist, that there is a filesystem, and that the process controls its own lifetime.

This article sets out what those assumptions are and where the web breaks them. It concerns the canvas-rendered wasmJs target, which runs the same Compose UI code as the desktop and mobile builds. It does not cover Compose HTML, the DOM-based library for conventional web pages.

The differences all follow from one observation:

On desktop, Android and iOS your code owns a process on a machine. On the web it is a guest in someone else’s event loop, holding a revocable lease on everything it touches.

They fall into four families, ordered by when they bite. Threading breaks the build, resources break the first run, lifecycle breaks the second run, and the runtime substrate breaks the dependency list.

Note

Version-sensitive statements were checked against Kotlin 2.4.10, Compose Multiplatform 1.12.0 and kotlinx-coroutines 1.11.0. The web target moves quickly, and browser behaviour for installed web apps moves faster still. Re-check anything that matters to you against current versions.

1. One thread, and it is drawing your UI

Desktop and mobile give you real threads: Dispatchers.IO for blocking work, Dispatchers.Default as a parallel CPU pool, and runBlocking to bridge blocking and suspending code. Moving a 200 ms computation off the UI thread is one withContext away.

On wasmJs there is one thread, the browser’s main thread. It runs layout, paint, event dispatch and the Compose frame loop.

API On wasmJs

Dispatchers.Main, Dispatchers.Default

Both exist, and both run on the same single event loop. Default is not a thread pool.

Dispatchers.IO

Does not exist.

runBlocking

Does not exist. Non-suspending code cannot wait for a coroutine.

Threads, locks, atomics

Not available.

The consequences:

  • withContext(Dispatchers.Default) compiles and freezes the app. The code still looks correct, but the work only moves to a later point in the same event loop, and the frame that should have been painted is not painted. This is the most dangerous difference because nothing reports it.

  • Blocking facades cannot exist. A synchronous fun load(): Data over an asynchronous implementation is routine elsewhere and impossible here. Suspension has to propagate outward until it reaches an entry point that can accept it, sometimes main itself.

  • The frame budget is the whole budget. A 200 ms parse that is invisible on a background thread elsewhere costs twelve dropped frames here.

  • Single-threaded does not mean race-free. Data races are gone, but a coroutine can still resume after another has changed the state it was reading. A Mutex is still needed for critical sections that span a suspension.

Web Workers add cores, but not shared memory. Each worker runs on its own operating-system thread, and Kotlin/Wasm code runs in one since Kotlin 2.0.20 (KT-68453). But each worker is a separate Wasm instance with its own heap, reached only by message passing. Nothing in a Compose state graph can be handed across; it must be serialised. There is also no build support: a worker means a separate Wasm target, your own bundler configuration and a hand-written message protocol. Parallelism pays only for work that splits into independent chunks, each large enough to justify its marshalling. WebKit also reports navigator.hardwareConcurrency as only 4 or 8 on every Apple platform, whatever the hardware: a six-core iPhone says 4.

Nothing runs while the app is not running. Off-main-thread work is possible; background execution is not. A service worker can be woken briefly for a push message or, in Chromium, for background sync. It has no access to your application’s state. Android’s WorkManager and iOS background tasks have no web equivalent.

2. Nothing is simply there

Desktop and mobile applications run on a furnished machine: a filesystem, a font book, a network stack, an operating system that presents a file dialog on request. In the browser every resource is some combination of absent, asynchronous, quota-limited, permissioned and revocable.

How far that is from what you are used to depends on your starting point. Mobile applications, and desktop builds shipped through the Mac App Store, MSIX, Flatpak or Snap, are already sandboxed. The unsandboxed desktop build has the furthest to fall.

Storage is a cache, not a disk

There are no paths, no working directory and no application data directory. The substitutes are localStorage (synchronous, string-only, about 5 MB), IndexedDB (asynchronous, structured), the Cache API (HTTP request and response pairs) and the Origin Private File System, OPFS (file-like, newest).

The API shape is not the real break. Ownership is. IndexedDB, the Cache API and OPFS are all subject to eviction under storage pressure. An entry written last week can be gone with no event and no error. Every read has to treat a miss as a normal outcome. An emulated filesystem on top of OPFS recovers File-like ergonomics but not durability, because it sits on the same evictable storage.

Two further consequences:

  • Two tabs are two instances sharing one origin’s storage. On the web a second concurrent writer is the default case, not an exception to prevent.

  • Storage belongs to the origin. Move the application to another host and its stored data is gone.

iOS applies the tightest limits of any platform: smaller quotas, capped lifetimes for script-written storage, and persistent-storage requests tied to notification permission. These details have changed repeatedly. If offline data on iOS matters, measure it on a current device, and never treat the device as the only copy of anything.

The one durable store is the user’s own filesystem. The File System API’s picker methods (showOpenFilePicker, showSaveFilePicker, showDirectoryPicker) reach real files that the user can see and back up, and their handles can be kept in IndexedDB and reopened in a later session. They are supported only by Chrome and Edge on desktop. Firefox, Safari and Chrome on Android do not have them. What works everywhere is import and export: a file input to bring bytes in, a download to send them out. That loses the live file handle but still puts the data where the browser cannot evict it.

Devices: available, but harder to display

Cameras, microphones and location are ordinary web capabilities. getUserMedia and the Geolocation API work in every current browser, so scanning QR codes and barcodes with the camera is straightforward. Web Bluetooth, WebUSB, Web Serial and WebHID are Chromium-only. WebKit does not implement them and its published position on WebUSB is opposed. Since every browser on iOS uses WebKit, none of these is coming to iOS. Even the supported capabilities have been unstable there: camera access in home-screen web apps broke and was repaired more than once between iOS 17 and 18.1.1.

Reaching the camera is the easy half. Showing the stream inside a canvas-rendered UI costs something the other targets do not pay. A <video> element in a WebElementView performs well, but it is an overlay rather than a composited layer. It sits above the canvas, takes the input events within its bounds, and cannot be clipped or transformed by Compose. The alternative is to copy every frame into an ImageBitmap. That composites properly, but costs a CPU round-trip per frame on the one thread that also paints the UI. This is not a limitation of the web platform. It is the cost of drawing the UI into a canvas, and it recurs wherever a web API produces pixels rather than data.

Printing and scanning

Printing loses the document model. Desktop and mobile print through a platform print system with pagination, printer selection and page setup. The web offers window.print(), which opens the browser’s dialog. The page cannot select a printer, set duplex or trays, print silently, or even learn whether the user printed or cancelled. And since browser printing renders the DOM, while a Compose UI lives in a canvas, printing the screen produces a picture of the viewport rather than a paginated document. The workable route is to generate a document, usually a PDF, and hand that over.

Direct device access exists conditionally. WebUSB and Web Serial drive real receipt printers, label printers and barcode scanners, but only in Chromium, so not on iOS. On Windows, an installed printer driver claims the device exclusively and blocks WebUSB.

Document scanners have no web API at all. There is no equivalent of TWAIN, SANE or WIA. Web products that offer scanning install a local helper service that the page talks to over HTTP. That generalises: when the browser cannot reach a device, the remedy is a native helper process. That reintroduces a per-platform, signed, self-updating installer, and browsers increasingly guard the route to it (see Networking through a keyhole).

Fonts

Skia rasterises text into the canvas and has no access to the system font book. Up to Compose Multiplatform 1.11, anything the bundled fonts did not cover rendered as empty boxes. Since 1.12.0 the web target downloads the required Noto subsets on demand. That fallback needs the network at that moment, shows the missing glyphs until the download completes, and uses Noto rather than your typeface. What must be readable offline, and a branded typeface, still has to be bundled.

The operating system is out of reach

There are no system file dialogs, no tray, no global hotkeys, no multiple OS windows, no menu bar, no file associations, no share-sheet targets and no widgets. Dialogs and popups render inside the canvas and cannot extend beyond the viewport. Keyboard shortcuts such as Cmd+W, Cmd+T and Cmd+N belong to the browser, and some cannot be intercepted at all. Right-click competes with the browser’s own context menu.

The list does shrink, sometimes unexpectedly: 1.12.0 added Modifier.keepScreenOn (the Screen Wake Lock API) and haptic feedback on the web.

Privileged actions need a gesture, a permission, or both

Clipboard access, notifications, fullscreen, audio playback, persistent-storage requests, file pickers and downloads require user activation: they must start from a real user event. Several also need a permission that the user can refuse permanently. Activation is transient and expires quickly, so do not rely on it surviving an await. A file picker opened after an intervening suspension may be blocked as an unsolicited popup.

Networking through a keyhole

There is no raw TCP or UDP. What remains is fetch, WebSocket and server-sent events, under rules the other targets do not impose:

  • CORS. A cross-origin request needs the server’s cooperation. Custom headers and non-simple methods trigger a preflight request that the server must also answer.

  • No control over the transport. There is no custom User-Agent, no client certificate handling by the application, no certificate pinning and no control over redirects or connection reuse.

  • Mixed content and the local machine. An HTTPS page cannot call HTTP endpoints, and an installable PWA requires HTTPS. Chrome and Firefox exempt loopback addresses (localhost, 127.0.0.1) from that rule, but both now ask the user’s permission before a public site may reach them: Chrome since version 142, Firefox in a staged rollout. Safari makes no exemption at all. A locally installed helper service is therefore reachable only after a permission prompt, and not from Safari or any browser on iOS.

  • Cookies are managed by the browser, not by your client code.

As a result, the server becomes part of the client’s architecture. An endpoint that serves the desktop and mobile builds can be unreachable from the web build for reasons that live entirely in its response headers.

3. The application is a document

This is where desktop and mobile differ most from each other. A desktop application owns its process: it decides when to quit, can veto a close, and keeps whatever is in memory. Android and iOS took that away long ago; processes are suspended and killed, and state must be saved and restored. A codebase that already survives Android process death is most of the way to surviving a web reload. For a desktop-only codebase, this family is where the real work lies.

On the web the application is a document in a tab:

  • Shutdown is not yours. The user closes the tab. beforeunload is a hint the browser may ignore. "Are you sure you want to quit?" cannot be enforced.

  • Reload is a restart. All in-memory state is lost. The back/forward cache can also do the opposite: freeze the application and restore it later with memory intact but the clock moved on. Both must be survivable.

  • Identity lives in the URL. Back and forward are navigation inputs, and a URL is expected to be shareable and restorable. In-memory navigation state has to be mirrored to browser history.

  • Back means document history. In an installed PWA on Android, the system back gesture walks browser history while the user expects app navigation, and the two disagree as soon as a screen pushes more or fewer than one entry. A home-screen web app on iOS has no back control besides an edge swipe, so the application must provide its own.

  • Startup is a download, once. A multi-megabyte module must be fetched, compiled and instantiated before main runs. Compression and streaming compilation reduce this, and after the first visit the service worker serves it from local storage. It remains a cost on every first visit, which is also every first impression.

  • Background tabs are throttled. Timers slow down or stop. Anything time-based has to reconcile against the real clock when the page becomes visible again rather than count ticks.

  • Crashes are harsher. An exception escaping the frame loop can leave a dead canvas, and the stack trace is only readable if source maps are served.

The software update model differs less than it appears. A desktop application with an auto-updater also detects, announces and applies updates in session. The web replaces the installer with a reload, but misconfigured caching leaves clients on old code silently and indefinitely. That subject is covered in Shipping a Kotlin Compose Multiplatform app as a PWA: getting updates and caching right.

4. The runtime and the interop boundary

This family breaks the build rather than the behaviour, which makes it the least dangerous and the most tedious.

The escape hatch is JavaScript. Desktop and mobile reach platform code through JNI, cinterop or the Android SDK. On wasmJs the route is JavaScript, and it is narrow: a js("…") body must be the sole expression of a top-level function, only JsAny and its relatives cross the boundary, exceptions do not cross it cleanly, and asynchronous results arrive as a Promise. Wasm is also a target for C, C++, Rust and Zig, so an open-source library can often be compiled to Wasm yourself. It then runs as a separate module with its own memory, and all data passing between it and Kotlin is copied. Nothing helps with a closed-source vendor SDK or with a library whose purpose is to call an OS API the browser does not expose.

The library universe is narrower. Only artifacts that publish a wasmJs klib are usable. Coroutines, serialization, datetime, Ktor and Compose itself qualify. Anything wrapping a native library, most cryptography beyond Web Crypto and most document and image processing do not. A codebase that already targets iOS has done most of this audit.

Common code behaves differently. Regex compiles to a JavaScript RegExp, so lookbehind, Unicode property classes and some escapes behave differently from the JVM. A green JVM test suite is evidence, not proof. Number, date and locale formatting has no strong common story on any target, and dropping the JVM makes that unavoidable. Reflection is unavailable, as it largely is on Kotlin/Native.

Tooling is slower. There is no hot reload on wasmJs. Debugging means browser developer tools and source maps. Tests run in a browser, builds take longer, and PWA packaging depends on Node.

The browser baseline is a hard floor. Kotlin/Wasm requires WebAssembly garbage collection: Chrome 119, Firefox 120 or Safari 18.2. On iOS and iPadOS every browser is WebKit, so the browser baseline is the operating system baseline. Below iOS 18.2 the application does not run at all. Compose Multiplatform can bundle a Kotlin/JS build as a fallback, but that turns the web target into two targets. Platform code then belongs in a shared webMain source set, and Kotlin/JS brings its own numeric quirks, such as emulated Long.

What cannot be worked around

Some limits have no common abstraction, and an API that pretends otherwise will not be honest:

  • Shared-memory parallelism. Workers add cores but share nothing.

  • Background execution. Scheduled work and sync engines move to the server or do not exist.

  • Arbitrary network protocols. A custom TCP protocol needs a server-side gateway.

  • Closed-source platform SDKs, and anything whose value lies in an OS API the browser does not expose.

  • Durable storage the application controls. Data becomes durable only when the user puts it on their own filesystem, as an explicit act.

  • OS integration: tray, global hotkeys, multiple windows, system menus, file associations, widgets, share targets.

  • Peripherals beyond WebUSB and Web Serial, above all document scanners and printers with programmatic control. The workaround is a native helper, which brings back the installer and works only where the browser lets the page reach the local machine.

  • The first-visit download. It can be reduced, not removed.

  • Accessibility parity. Support is on by default and improves with every release, but it is still behind the platform bridges. Evaluate it against a current build if it is a hard requirement.

What could change

Some of these limits reflect where the platform stands today rather than what it is.

  • Shared-everything threads. The proposal would bring threads to the WasmGC memory model that Kotlin/Wasm uses, which would remove most of family 1. JetBrains has a prototype (KT-80304), but the proposal is at Phase 1 and no browser implements it. Expect years.

  • JSPI. JavaScript Promise Integration lets Wasm suspend on a JavaScript promise, which could make blocking bridges possible again. It is standardised and ships in Chrome and Firefox; WebKit has not committed to shipping it, and Kotlin/Wasm would still have to adopt it.

  • The baseline ages out. Every month, more devices run iOS 18.2 or later. This improves without anyone deciding anything.

  • Maturity. Kotlin/Wasm and Compose for web are both Beta, and accessibility improves with each release. These gaps close at release speed rather than standards speed.

  • iOS storage and background rules have changed repeatedly and not always in the same direction. Measure them; do not forecast them.

None of these should change a decision made today. They are a reason to revisit a well-founded "not yet" about once a year.