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 |
|---|---|
|
Both exist, and both run on the same single event loop. |
|
Does not exist. |
|
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(): Dataover an asynchronous implementation is routine elsewhere and impossible here. Suspension has to propagate outward until it reaches an entry point that can accept it, sometimesmainitself. -
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
Mutexis 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.
beforeunloadis 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
mainruns. 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.