Skip to content
Blog

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:

  1. The browser re-fetches sw.js on navigation (subject to HTTP cache) and compares bytes. Any byte difference triggers an update.
  2. The new worker enters installing → fires updatefound; if it doesn’t call skipWaiting(), it sits in waiting behind the old worker while old tabs stay open.
  3. Once all old tabs close (or skipWaiting() is called), it activates, and the page gets a controllerchange event.

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 typeStrategyWhy
Versioned assets (main.dart.js, assets/*, .vec)Cache-first (precache)Content-hashed; immutable
HTML shell (index.html)Network-first with cache fallbackAlways try for fresh, fall back offline
Read-only API (GET /feed)Stale-while-revalidateInstant paint, silent refresh
Mutations (POST, GraphQL writes)Network-onlyNever 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