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 |
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
The UI then shows a small status dot — "live", or "offline — showing cached copy". 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.
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.
-
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.