Kotlin Compose Multiplatform lets you write one UI and run it across a whole
range of targets — Android, iOS, desktop (JVM) and the web via WebAssembly
(wasmJs), among others. Which subset you care about depends on your project;
the web target is the one that changes the rules, and it’s the subject of this
post.
You don’t have to make it a PWA — but here’s why you might
The wasmJs target runs in the browser as an ordinary web app. You can serve the
static files the build produces and you’re done: open a URL, the app loads. No
service worker, no manifest, nothing special.
You can, however, go one step further and package that same output as a Progressive Web App (PWA). You are not required to — the reason to do it is to make the app behave like a desktop or mobile app rather than a web page: installable to the home screen or dock, running in its own window without browser chrome, launchable offline, and generally indistinguishable from a native install. For an app that’s already architected like a desktop/mobile app on its other targets, that’s exactly the experience you want on the web too. That is the motivation here.
The moment you take that step, a service worker appears between your app and the network — and that’s where the subtleties begin.
The plugin doing the work: ComposePWA + Google Workbox
The PWA packaging described here is handled by the ComposePWA Gradle plugin
(dev.yuyuyuyuyu.composepwa) —
github.com/yuyuyuyuyu-dev/ComposePWA.
Under the hood it uses Google’s Workbox to
generate the service worker from a small config file — the examples below assume
it lives at webApp/workbox-config-for-wasm.js.
All paths in the examples that follow assume a default Kotlin Multiplatform / Compose Multiplatform (CMP) project structure, as produced by any of the usual project setup wizards; adjust them if your module layout differs.
That detail matters for everything below: when the rest of this post talks about "the Workbox config" or "the generated `serviceWorker.js`", that’s the file the ComposePWA plugin produces via Workbox. Knowing where the worker comes from is what makes the configuration choices make sense.
|
Note
|
Troubleshooting:
Cannot run program "npx"Because Workbox is a Node tool, the build shells out to a bare The cure is to get rid of that daemon:
It looks maddeningly intermittent because two things hide it: the task is skipped
while |
The mental model that makes everything click
The single most useful idea when taking a Compose Multiplatform app to the web is to keep a hard line between two things:
-
Software — the static files your build produces (
.wasm,.js,index.html, fonts, images, …). These are uploaded to the server and are identical for every user until you deploy again. -
Data — everything the app fetches at runtime: API responses, documents, a "message of the day". This changes independently of deploys and is often per-user.
On desktop and mobile this separation is obvious — the software is the installed binary, the data is whatever the app downloads and caches on disk. The web platform blurs the line, because the browser is happy to cache both through the same machinery. A PWA’s job is to restore that separation deliberately:
The service worker owns the software cache. The app owns the data cache. Neither should reach into the other’s territory.
Everything below follows from that one sentence.
Part 1 — Software: making updates land on the first relaunch
The failure mode
A common starting point is a Workbox config that precaches nothing and instead
routes everything through a StaleWhileRevalidate strategy:
runtimeCaching: [{ urlPattern: /.+/, handler: "StaleWhileRevalidate" }]
This produces a frustrating "two launch" lag after every deploy, for two compounding reasons:
-
StaleWhileRevalidateis one launch behind by definition. It serves the old cached asset instantly and fetches the new one in the background; the new copy is only shown on the next launch. -
The worker’s bytes never change. With nothing precached, the generated
serviceWorker.jshas no content hashes, so the browser’s update check sees "no change" and never installs a new worker — soskipWaiting/clientsClaimand any reload logic never actually fire.
The fix: precache the software
Tell Workbox to precache the build output. This embeds a per-file revision
manifest into serviceWorker.js, so the worker’s bytes change on every deploy,
the browser detects a new worker, and the update flow runs.
module.exports = {
globDirectory: "build/dist/wasmJs/productionExecutable/",
// The software: everything the build produces. This is the ONLY thing the
// service worker caches. A per-file revision manifest is embedded, so the
// worker's bytes change on every deploy → clean update detection.
globPatterns: [
"**/*.{js,wasm,html,json,ico,png,svg,css,woff,woff2,ttf,txt}",
],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
swDest: "build/dist/wasmJs/productionExecutable/serviceWorker.js",
// Activate the new worker immediately and take control of the open page.
skipWaiting: true,
clientsClaim: true,
// Drop precaches from previous versions once the new worker activates.
cleanupOutdatedCaches: true,
};
Pair it with a registration script that checks for updates and reloads once the new worker takes control:
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!refreshing) { refreshing = true; window.location.reload(); }
});
navigator.serviceWorker.register("serviceWorker.js").then((registration) => {
registration.update(); // check for a new worker on load
});
Now a returning user runs the new software on the first relaunch:
registration.update() fetches the new (byte-changed) worker → it precaches the
new assets → skipWaiting activates it → clientsClaim takes control →
controllerchange fires → the guarded reload swaps the page to the new version.
Updating a running app
The step above catches updates at page load. But a Compose Multiplatform app is often left open for a long time — more like a desktop app than a page you navigate away from. To let a running instance notice a new deploy, poll for updates while the tab is alive:
// 10s here is a TESTING interval — use minutes in production.
// Simplified: update() rejects when offline, so add a .catch() before you
// ship — see "pull the plug" at the end of Part 2.
setInterval(() => registration.update(), 10 * 1000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") registration.update();
});
With skipWaiting + clientsClaim still in place, a new build found by the poll
activates and reloads the open tab on its own — no manual relaunch.
|
Caution
|
This reloads silently. For a stateless app that’s fine. If the user might have
unsaved state, prefer the notify pattern instead — set |
The one line of server config that makes the poll real
Polling is worthless if the browser answers the poll out of its own HTTP cache, and that is the default outcome on a plain static host.
registration.update() re-fetches serviceWorker.js through the ordinary HTTP
cache. If the server sends Last-Modified and ETag but no Cache-Control
— which is exactly what a stock Apache or nginx does — the browser falls back to
heuristic freshness, conventionally about 10% of the resource’s age. The update
check is then served from cache without touching the network at all, and your
deploy goes unnoticed.
The result is a poll that only looks like it works: you check every few minutes, but detection lags by an interval that grows with how long ago you last deployed (the service-worker spec caps it at 24 hours). Everything else in this post can be correct and the update will still arrive late.
Tell the browser to always revalidate. For a static PWA host, applying this to the whole deployment is the simplest thing that is correct:
# Always revalidate; never serve a stale version.
<IfModule mod_headers.c>
Header set Cache-Control "no-cache"
</IfModule>
Despite the name, no-cache does not disable caching — it means "you may
store this, but you must revalidate before reusing it". That is precisely what
you want from the file whose entire job is to signal "something changed", and the
cost is nothing: a revalidation that finds no change is a 304 with an empty
body.
no-cache vs. no-store — pick the first one
The obvious-looking stronger option is a trap worth naming, because "disable caching" is what most snippets on the web will hand you:
# Works, but strictly worse here.
Header set Cache-Control "no-cache, no-store, must-revalidate"
no-store says "never write this down at all". It is equally correct — you will
never serve a stale version — but the browser now holds no validators, so it has
nothing to send an If-None-Match with. Every check becomes a full download
instead of a 304. You have paid bandwidth for a guarantee no-cache already
gave you.
must-revalidate is redundant alongside no-cache (it governs what happens once
a response goes stale, and under no-cache a response is never reusable without
checking first). Companion Pragma: no-cache and Expires: 0 headers are
HTTP/1.0-era fallbacks — harmless, but no browser you are targeting needs them.
The one real reason to reach for no-store is privacy: keeping sensitive
responses off disk. Static app assets and public data are not that.
|
Tip
|
Worth knowing what the polling actually costs, because the answer surprises
people. In the steady state both the software check and a conditional data
request come back When a deploy does land, only And no, you don’t need a long |
The users you cannot reach: replacing an older service worker
Everything in Part 1 assumes the browser will load your new index.html. If a
previous build of your app already registered a hand-written service worker —
the usual first attempt, precaching the shell and serving it cache-first —
that assumption fails for exactly the users you most need to reach:
-
The old worker serves the old
index.htmlout of Cache Storage. -
That shell loads the old entry script, not your new registration code.
-
So none of the new code runs. Not the registration, not the startup gate, not any cleanup logic you were planning to add.
Deploying the new version does nothing for those clients. They are not choosing the old build; they never learn a new one exists. No amount of code in the new build can reach them, because the new build is never fetched.
The one channel that still works is the old worker’s own update check: the browser periodically re-fetches the URL the old worker was registered from, and that request goes through the ordinary HTTP path rather than the worker’s cache. So serve a tombstone there — a valid script, at the old worker’s exact filename, whose only job is to switch its predecessor off:
// sw.js — not a service worker for this app any more. It exists to retire the
// previous one: skipWaiting, drop the retired precaches, claim the open tabs,
// unregister itself, then navigate each tab so it reloads from the network with
// no controlling worker left. Deliberately NO fetch handler: a worker without
// one is bypassed entirely, so requests reach the network even before the reload.
Deleting the old file instead (letting it 404) also clears the registration in Chrome eventually, via a failed update check — but it is browser-dependent, undated, and it leaves the old multi-megabyte precache on the device forever.
|
Warning
|
When the tombstone deletes the retired precaches, match the old cache names with
a regex anchored on the version segment, never |
The tombstone is a deployment obligation with a lifetime, not a file you land and forget: it must stay at that URL until you are confident the last stale client has come back. Pick that date from how long a user might plausibly stay away — for an app tied to a yearly event, comfortably more than a year — and write the reasoning into the file header, because nothing else will remind you. Delete it early and you strand precisely the users it existed for. There is no telemetry for "the last old client returned"; when in doubt, keep it. It costs kilobytes.
Part 2 — Data: keeping the service worker out of it
Here is the part that surprises people. A service worker intercepts every
fetch() a controlled page makes — regardless of destination. On wasmJs,
Ktor’s client engine ultimately calls the browser’s fetch(), so your Ktor
requests pass through the service worker too. Ktor has no idea the worker is
there, and you cannot opt out from the Kotlin side (request Cache-Control
headers don’t override a Workbox strategy — the strategy decides).
That means the innocent-looking catch-all from earlier…
runtimeCaching: [{ urlPattern: /.+/, handler: "StaleWhileRevalidate" }]
…doesn’t just "cache dynamic assets". It caches your API responses, and serves them stale (one request behind), and quietly accumulates them in Cache Storage. For live data that’s a bug, not a feature.
The correct config: no runtime caching at all
If the app manages its own data cache — which it should, because that’s the
abstraction that already works on every other platform — then the service worker
should cache only the software and touch nothing else. Concretely: remove
runtimeCaching entirely.
module.exports = {
globDirectory: "build/dist/wasmJs/productionExecutable/",
globPatterns: [
"**/*.{js,wasm,html,json,ico,png,svg,css,woff,woff2,ttf,txt}",
],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
swDest: "build/dist/wasmJs/productionExecutable/serviceWorker.js",
// NO runtimeCaching on purpose. The worker caches ONLY the static software
// (the precache above). Everything else — notably the app's Ktor data
// requests — is not intercepted and falls straight through to the network,
// leaving the app's own cache the sole owner of data.
skipWaiting: true,
clientsClaim: true,
cleanupOutdatedCaches: true,
};
With no matching route, Workbox’s fetch handler simply doesn’t call
respondWith() for a data request, so the browser handles it as a normal network
fetch. The worker still sees the request (a negligible routing check) but neither
answers nor caches it. Your data layer is back in sole control — exactly as it is
on desktop and mobile.
The app-owned data layer
On the app side, data lives behind a small Store abstraction backed by the
browser’s Cache API. It is tempting to reach for localStorage instead —
it’s synchronous and takes one line — but for anything beyond a scratch value it
is the wrong tool: a few megabytes of quota, strings only, and it blocks the main
thread. The Cache API is the one built for this job, and it stores a body plus
its headers, which turns out to matter more than it sounds.
/** One entry: the payload together with the headers it arrived with. */
class StoredEntry(val text: String, val headers: Map<String, String>) {
operator fun get(name: String): String? =
headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value
}
expect object Store {
suspend fun get(key: String): StoredEntry?
suspend fun put(key: String, entry: StoredEntry)
suspend fun remove(key: String)
}
Every operation is suspend — the Cache API is promise-based, and file I/O on
the desktop target shouldn’t run on the UI thread either.
Two details are worth calling out, because both bite people:
-
Cache entries are keyed by request URL. App-owned data therefore needs a synthetic key — here
/app-data/<key>. It is never fetched over the network; it exists purely as a cache key, and the prefix keeps it visibly distinct from the software URLs sitting in the Workbox precache. -
Cache Storage is evictable. Under storage pressure the browser may discard entries. A miss must be an ordinary code path, never an error — which is exactly right for a cache anyway.
Why storing the headers pays off
Because the entry keeps the server’s real Last-Modified header, revalidating it
with a conditional GET needs no bookkeeping of our own — you read the header
back out of the cache and send it straight up as If-Modified-Since:
object MessageStore {
private const val KEY = "motd"
private const val FETCHED_AT = "X-Fetched-At" // our own header, for staleness
suspend fun load(): CachedMessage? = Store.get(KEY)?.let { entry ->
CachedMessage(entry.text, entry[HttpHeaders.LastModified], entry[FETCHED_AT] ?: "?")
}
suspend fun save(text: String, lastModified: String?): CachedMessage { /* Store.put(...) */ }
}
// One engine per platform is on the classpath, so HttpClient() auto-selects it.
// Default config does not throw on non-2xx, so a 304 is just a response we read.
private val client = HttpClient()
suspend fun refresh(): CachedMessage? {
val cached = MessageStore.load()
val response = client.get(URL) {
// The stored header, sent straight back up. No hand-rolled metadata.
cached?.lastModified?.let { header(HttpHeaders.IfModifiedSince, it) }
}
return when (response.status) {
HttpStatusCode.NotModified -> cached // 304: keep what we have
HttpStatusCode.OK -> MessageStore.save( // 200: store the new copy
text = response.bodyAsText().trim(),
lastModified = response.headers[HttpHeaders.LastModified],
)
else -> cached
}
}
Had the entry been a JSON blob in localStorage, that lastModified field would
have to be invented, serialized and kept in sync by hand. Storing a real response
with real headers means the revalidation metadata simply is the cache entry.
The per-platform actual for Store is a thin Cache API wrapper on wasmJs.
On the JVM desktop build it’s a directory of files, one per key,
each written in HTTP message shape — header lines, a blank line, then the body.
Same body-plus-headers contract, so every line of the code above stays common.
The Compose UI just polls refresh() on a timer and renders the cached text.
|
Note
|
Kotlin/Wasm ships no bindings for |
Two caches, one Cache Storage
Workbox’s precache and the app-owned Store now live in the same Cache
Storage, partitioned by cache name. That is not a problem — it’s the design —
as long as the names don’t collide:
-
Workbox owns caches named
workbox-precache-*. -
Give your data cache its own distinct name (
app-data-v1here) and don’t prefix it withworkbox. -
cleanupOutdatedCaches: trueonly deletes old Workbox precaches — it matches Workbox’s own naming scheme and will never touchapp-data-*.
The payoff of the separation shows up here: deploying new software swaps the precache and reloads the tab, while the app’s data cache carries across the update completely independently — precisely the behavior you’d expect from a desktop/mobile app.
The test that proves it: pull the plug
Everything above can be checked by reasoning. This one you can check with your hands: unplug the network cable with the app running. The app keeps running, the message stays on screen, and when you plug back in, polling resumes on its own.
That sounds like one feature. It is three independent mechanisms that happen to line up, and it is worth knowing which is which — because they fail separately too:
-
The app keeps launching. The Workbox precache serves the software from Cache Storage; no network is involved at any point. This is the service worker’s contribution, and it is the only one of the three you get for free.
-
The message keeps rendering.
Store.get()treats the cached entry as authoritative and never consults the network to decide whether to return it. This is the app-owned data cache doing exactly the job the whole of Part 2 argued for. -
Polling recovers by itself. No connectivity listener, no retry policy, no exponential backoff. The failed request is caught inside the polling loop, so the next tick simply tries again — the loop is the recovery.
Point 3 deserves a caveat, because the naive version of it is a trap:
// Don't do this: the failure vanishes without a trace.
runCatching { motd = MessageService.refresh() }
Swallow the error and a permanently broken endpoint becomes indistinguishable from a healthy app showing a cached copy. The user sees plausible-looking content that quietly stopped being true. Record the outcome instead, and say so:
runCatching { MessageService.refresh() }
.onSuccess { motd = it; offline = false }
.onFailure { offline = true } // an ordinary outcome, not an exception
There is a sharper version of the same trap, and this one is specific to
Kotlin/Wasm. runCatching above is not a stylistic choice — it catches
Throwable, and on this target that is the difference between working and
broken:
// Looks equivalent. Is not. On wasmJs this does not catch an offline browser.
try { MessageService.refresh() } catch (e: Exception) { offline = true }
A failed fetch() never arrives as an Exception here. kotlin.js.JsException
extends Throwable directly, and Ktor’s JS engine reports fetch failures as
kotlin.Error — also a Throwable, also not an Exception. So the throw sails
straight past that catch, past the caller, and out of the polling coroutine.
The symptom triple is distinctive once you have seen it: the offline indicator
never appears, reconnecting the cable fixes nothing, and only a reload brings the
app back. And because the identical code is correct on the JVM — where Ktor
throws IOException — it passes every test you run on the desktop target.
Catch Throwable and rethrow CancellationException, or use runCatching and
leave a comment saying why. While you are there, make sure the loop itself
survives: if the throw can end the while, the first blip stops polling for the
lifetime of the page.
The UI then shows a small status dot — "live", or "offline — showing cached copy". Give it a label rather than leaving it a bare coloured dot; an 8 dp dot in the corner of an app bar is genuinely missable, and "there was no offline warning" is a bug report you will otherwise receive about an indicator that was working perfectly. Note what that indicator is scoped to: it reports the health of the data poll only. Software freshness is a separate question with a separate mechanism, and collapsing the two into one "offline" lamp would blur the very line this post is about. The cached entry already carries the timestamp it was stored with, so surfacing how stale the data is costs nothing extra.
|
Note
|
The software update check needs the same treatment, for the same reason.
|
A CORS caveat worth internalizing
One browser-specific wrinkle has no analogue on other platforms. A conditional GET
sends an If-Modified-Since header, which is not a CORS-safelisted request
header. Cross-origin, that triggers a preflight OPTIONS request that a plain
static host won’t answer, and the request fails. Same-origin (the app served from
the same host as the data), there’s no preflight and it just works.
|
Tip
|
Test against the real deployment, not a cross-origin |
How to verify you got it right
-
Build log: it should say
The service worker will precache N URLs(your software), and the generatedserviceWorker.jsshould contain aprecacheAndRoute([…])manifest and noregisterRoute/StaleWhileRevalidate. -
DevTools → Network: software requests show "(ServiceWorker)" in the Size column; your data requests do not.
-
The update check really leaves the machine: watch
serviceWorker.jsin the Network panel across two poll intervals. You want a304each time, not "(disk cache)" — the latter means heuristic freshness is hiding your deploys and theCache-Control: no-cacheabove is missing. -
DevTools → Application → Cache Storage: you see two caches side by side — a
workbox-precache-*one holding your software, andapp-data-v1holding your data under its synthetic/app-data/…keys. Your data must not appear inside the Workbox cache, and the software must not appear inside yours. -
Update behavior: deploy build A, open the app, deploy build B; within one poll interval the open tab reloads into B on its own — and
app-data-v1comes through that update untouched, which is the whole point of the separation.
After the deploy: check the precache against the live site
One check belongs on the deployed site rather than the build output, because it is the one whose failure takes everybody down at once: every URL in the precache manifest must return 200. Workbox installs the manifest atomically — a single 404 fails the entire install, so one missing file does not degrade gracefully, it stops the service worker working for every client. The symptom is maddeningly indirect: updates simply stop happening.
#!/bin/bash
set -euo pipefail
BASE="https://example.com" # deployment root, no trailing slash
curl -sS "$BASE/serviceWorker.js" \
| grep -oE '\{url:"[^"]+"' | sed 's/{url:"//;s/"//' \
| while read -r u; do
code=$(curl -sS -o /dev/null -w "%{http_code}" "$BASE/$u")
[ "$code" = "200" ] || echo "MISSING $code $u"
done
The way this actually happens is someone tidying the deployed directory by hand, and the wasm distribution offers two tempting candidates:
-
<yourModule>.js.map— genuinely safe to drop..mapis not inglobPatterns, so it is not precached, and nothing fetches it unless DevTools is open. You lose readable production stack traces; turn source maps off in the webpack config rather than deleting build output. -
<yourModule>.js.LICENSE.txt— do not. It is precached, because.txtmatches the glob patterns, so deleting it from a live deployment breaks the install for everyone. It also holds the BSD/MIT attributions webpack extracted out of the bundle — the ones the/*! For license information … */banner at the top of the bundle points at, and that redistribution requires you to keep. It costs about a kilobyte.
The rule underneath both: never delete files from a deployed distribution. Change what the build emits and redeploy, so the manifest and the directory always agree.
Two more that cost seconds. Ship a tiny version.json and fetch it after every
deploy — it is the fastest way to catch the most common deploy mistake there is,
uploading yesterday’s directory. And if you want to confirm a config constant
reached the bundle, grep the minified JS for it, remembering that 300000 is
written 3e5; unrelated numeric constants will match too, so presence of the
right one is the signal, not absence of others.
Takeaways
-
Precache the software so the worker’s bytes change on every deploy — that’s what makes updates land on the first relaunch.
-
Poll
registration.update()to let a long-running instance update itself, choosing silent-reload vs. notify based on whether the app holds user state. -
Don’t let the service worker cache data. Drop
runtimeCachingand let the app’s ownStoreown the data — the same abstraction that already works on every other Compose Multiplatform target. -
Back that store with the Cache API, not
localStorage. Storing a real response with real headers means the revalidation metadata is the cache entry, and it scales to datasetslocalStoragecan’t hold. -
Keep cache names disjoint so software updates never disturb cached data.
-
Serve everything with
Cache-Control: no-cache, notno-store— the browser must revalidate either way, but onlyno-cachelets it do so with a zero-byte304. -
Treat offline as an outcome, not an error. Record it and show it; a swallowed failure makes a dead endpoint look exactly like a healthy app.
-
Catch
Throwable, notException, around anything that fetches. On Kotlin/Wasm a failed fetch is aJsExceptionor akotlin.Error, socatch (e: Exception)misses it — and the escaping throw takes your polling loop with it, on a code path the JVM target will never show you. -
If you already shipped a service worker, retire it with a tombstone at its old URL. Clients behind a cache-first worker never load your new page, so it is the only channel you have left to them.
-
Set
no-cacheon the data host too, not just the app host. Heuristic freshness scales with a file’s age, so a rarely-changed manifest can be treated as fresh for a week. -
After deploying, check every precached URL returns 200. Workbox installs the manifest atomically, so one 404 stops updates for every client — which is why you never tidy a deployed distribution by hand.
-
Remember CORS for conditional requests: serve data same-origin, or the
If-Modified-Sincepreflight will bite you.
Treat the web target like the desktop/mobile app it really is — software in one cache, data in another — and the PWA layer stops being a source of mystery bugs and becomes just a delivery mechanism.
The files, ready to copy
Everything argued for above exists as a bundle you can lift into a project:
registerServiceWorker.js with the startup gate and the deferred-update logic,
the Workbox config, the tombstone for retiring an older worker, the PwaUpdate
expect/actual bridge, the update-notification and offline-indicator variants,
and two reference documents covering the update flow and the caching model.
Get the bundle here — Apache-2.0 licensed.
It is packaged as a skill for Claude Code, which
is how it was built and how it was applied to a real production app. You do not
need Claude Code to use it. Strip that framing and what remains is a folder of
plain files with {{PLACEHOLDER}} markers and two prose documents; SKILL.md is
readable as an ordinary step-by-step guide. If you do use Claude Code, dropping
the folder into .claude/skills/ lets it run the conversion for you, ask the
notification-style question, and audit your existing data layer against the six
checks in Phase B.
One caveat that applies either way: the bundle is validated against specific
versions — Kotlin 2.4.10, Compose Multiplatform 1.11.1, Ktor 3.5.1, Workbox 7.4.0,
ComposePWA 0.7.0-alpha03 — and a few of its sharper claims are pinned to them. The
JsException behaviour, the cleanupOutdatedCaches predicate and anything about
the (0.x, alpha) plugin are worth re-verifying if you are far from those numbers.
SKILL.md says which, and why.