Flutter Web Service Worker Updates: skipWaiting, Runtime Caching, and the Reload Prompt
Own the service worker update lifecycle on Flutter web — registration, updatefound, controllerchange, cache versioning, runtime caching for API calls, and coexisting with other workers.
Published on • November 13, 2026
AI Assistant

flutter-web-offline-support covers wiring Flutter’s precache manifest into a Workbox worker; flutter-web-pwa-support covers the manifest and build flags. Neither one owns the part users actually feel: the update cycle. When you deploy a new build, how does a running tab learn about it, when does the new worker take control, and how do you reload millions of sessions without stranding them on a stale main.dart.js? This post is that lifecycle, from register() to the reload prompt.
Registration: where Flutter apps put the worker
Your worker (say sw.js, built with workbox-build from Flutter’s precache_manifest.json) registers from the bootstrap in index.html:
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
const reg = await navigator.serviceWorker.register('/sw.js', {
updateViaCache: 'none', // always check the network for sw.js
});
watchForUpdates(reg);
});
}
updateViaCache: 'none' matters: without it, the browser may trust its HTTP cache for the worker script and delay discovering your new deploy. Flutter’s own bootstrap (in older templates, the flutter_service_worker.js logic) already demonstrates the pattern of waiting for activation before dropping the script tag — read it if you maintain an older project.
The update lifecycle you’re managing
The sequence from MDN’s service worker docs, compressed:
- The browser re-fetches
sw.json navigation (subject to HTTP cache) and compares bytes. Any byte difference triggers an update. - The new worker enters
installing→ firesupdatefound; if it doesn’t callskipWaiting(), it sits inwaitingbehind the old worker while old tabs stay open. - Once all old tabs close (or
skipWaiting()is called), it activates, and the page gets acontrollerchangeevent.
The classic Flutter/web bug lives in step 2: users keep tabs open for days, the new worker waits forever, and they run yesterday’s app. Two coordinated fixes:
// inside sw.js install handler
self.addEventListener('install', () => self.skipWaiting());
// inside sw.js activate handler — new cache namespace only
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k)))
).then(() => self.clients.claim())
);
});
skipWaiting() + clients.claim() force the takeover; the activate cleanup deletes old cache versions so you don’t accumulate every deploy’s assets forever.
One HTTP detail decides how fast step 1 even happens: the sw.js response headers. Serve the worker itself with Cache-Control: no-cache (or a very short max-age) — MDN’s guidance is that the browser checks for updates on navigations subject to that cache, so a year-long immutable header on sw.js means users won’t see your new deploy until their cached copy expires. Everything hashed inside the precache manifest can stay immutable; the worker script is the one file that must always be revalidatable.
The reload prompt: don’t hot-swap under the user
Forcing a reload the instant controllerchange fires can lose in-progress input. The gentler contract: listen for controllerchange, then show a non-blocking banner — “A new version is available” — and reload when the user taps it (or when they’re idle):
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return; // guard: controllerchange can fire multiple times
refreshing = true;
showUpdateBanner(() => window.location.reload());
});
The refreshing guard is straight from MDN’s ServiceWorkerContainer example pattern — without it, repeated events can reload the page twice.
Runtime caching: the other half of the worker
Precaching covers your versioned bundle. What it can’t cover is your API, and a Flutter app that boots offline with a dead backend isn’t really offline-capable. Add runtime routes in the same worker:
| Request type | Strategy | Why |
|---|---|---|
Versioned assets (main.dart.js, assets/*, .vec) | Cache-first (precache) | Content-hashed; immutable |
HTML shell (index.html) | Network-first with cache fallback | Always try for fresh, fall back offline |
Read-only API (GET /feed) | Stale-while-revalidate | Instant paint, silent refresh |
Mutations (POST, GraphQL writes) | Network-only | Never cache a write |
Name runtime caches separately (api-sws, api-cf) from the precache bucket so the activate cleanup never deletes API responses you still want, and put a size/age cap on them (expireAfterSeconds in Workbox) so they can’t grow without bound.
Coexisting with other workers
A scope can only have one active worker per registration — but Flutter apps routinely have more than one script wanting to be a worker: FCM’s firebase-messaging-sw.js (registered with its own /firebase-cloud-messaging-push-scope) and workmanager’s experimental /workmanager_service_worker.js. They coexist because scopes differ; what doesn’t coexist is two workers claiming the same scope — you’ll see one silently replace the other. Merge push handling into your main worker, or keep the separate-scope pattern Firebase documents, and verify in DevTools → Application → Service Workers that every registration you expect is activated.
One deployment habit closes the loop: after publishing a new build, confirm the update actually reaches users by checking DevTools → Application → Service Workers shows the new scriptURL fetch time and that the precache manifest hash changed. A deploy that only touches Dart code still changes main.dart.js, which changes the manifest — if the manifest looks identical, your build pipeline reused stale assets, and users will keep seeing the old app no matter how correct the worker code is.
Takeaway: Register with updateViaCache: 'none', call skipWaiting()/clients.claim() and clean old cache namespaces on activate, surface controllerchange behind a user-facing reload banner instead of reloading mid-tap, give API traffic its own capped runtime caches, and keep every extra worker in its own scope.
Sources
- https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer
- https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
- https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
- https://docs.flutter.dev/deployment/web
- https://firebase.google.com/docs/cloud-messaging/flutter/receive-messages