Service Worker Lifecycle Traps: Why Your Updates Are Not Showing Up

Networking & Caching

You deploy a new version of your service worker. Nothing changes for your users. You check DevTools and see "1 waiting" — the new service worker is installed but not active. You close and reopen the tab. Still nothing. You wait a day. Some users have the update. Most don't.

This is the service worker lifecycle in action, and it confuses everyone the first time they encounter it.

The Three Phases

Every service worker goes through three sequential phases:

Installing: The service worker script is downloaded and the install event fires. This is where you typically pre-cache static assets. If installation fails, the worker is discarded.

Waiting: The worker has installed successfully but cannot take control of any pages yet. It is waiting for all pages using the old service worker to close. This is the phase that catches developers off guard — it can last indefinitely if users never close their tabs.

Activating: The old service worker has been discarded, and the new worker takes over. The activate event fires here — typically used to clean up old caches.

// sw.js
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v2').then(cache => cache.addAll(['/app.js', '/styles.css']))
  )
})

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== 'v2').map(k => caches.delete(k)))
    )
  )
})

Why the Waiting Phase Exists

The waiting phase is a safety guarantee. If a new service worker activated immediately, it might serve different cached resources than the old controller expected — breaking pages that were loaded with the old service worker's cache. To prevent this, the browser enforces: one service worker version controls all open tabs for an origin at any given time.

Trap 1: The Long-Lived Tab

If a user opens your app and leaves the tab open for days, the old service worker retains control. Your new service worker sits in "waiting" until that last tab is closed. On mobile, users often have dozens of tabs open for weeks.

Trap 2: skipWaiting Changes Behavior

You can bypass the waiting phase with self.skipWaiting():

self.addEventListener('install', (event) => {
  self.skipWaiting() // immediately become active
  event.waitUntil(/* cache setup */)
})

This makes updates deploy immediately — but it breaks the safety guarantee. If the new service worker serves different assets than the old one expected, you can see inconsistencies where a page was rendered from old HTML but navigates using new JavaScript bundles.

The safer pattern: use skipWaiting only if you also reload the page after activation:

// In your service worker
self.addEventListener('activate', event => {
  event.waitUntil(clients.claim())
})

// In the page
navigator.serviceWorker.addEventListener('controllerchange', () => {
  window.location.reload()
})

clients.claim() takes immediate control of all open clients. The controllerchange event fires in each controlled tab, triggering a reload to load fresh assets from the newly active service worker. The user sees a brief reload, but they get a consistent page.

Trap 3: Workbox and Precache Versioning

Workbox generates a precache manifest with a revision hash for each file. If you use workbox-precaching and your build does not change the revision hash (e.g. during development), Workbox treats the service worker as unchanged and never triggers an update.

If you are not seeing service worker updates in development, ensure your build tool regenerates the service worker with updated revision hashes on each build.

Trap 4: Update Check Delay

The browser checks for a new service worker at most once every 24 hours. More precisely: it checks on every navigation to a page controlled by the service worker if the last check was more than 24 hours ago.

In practice this means a user who visits your app daily will receive updates within 24-48 hours of deployment. Users who visit less frequently may take longer.

You can trigger an immediate check programmatically:

const registration = await navigator.serviceWorker.getRegistration()
if (registration) await registration.update()

Nextjs's next-pwa and similar libraries often call registration.update() on focus events to minimize the update lag.

Trap 5: Serving the Service Worker With Wrong Cache Headers

The service worker script itself has special caching rules. If the web server caches sw.js with Cache-Control: max-age=86400, the browser uses the cached version and never checks for updates — even when you deploy a new one.

Service worker scripts should always be served with:

Cache-Control: no-cache

Or at most a very short TTL. Most CDN configurations need explicit rules to prevent service worker files from being cached aggressively.

Forcing an Update in Development

In Chrome DevTools → Application → Service Workers:

  • "Update on reload": Forces the service worker to update on every page reload — removes the 24h rule
  • "Skip waiting": Forces the waiting service worker to activate immediately

Both are development tools only. Do not rely on them as production patterns.

Conclusion

The service worker lifecycle is precise but not intuitive. The waiting phase is a deliberate safety mechanism, not a bug. Getting updates to users quickly requires a deliberate strategy: use skipWaiting + clients.claim() + a page reload on controllerchange if you prioritize fast updates over strict consistency. Ensure sw.js itself is served without caching. And understand that even with everything correct, updates may take up to 24 hours to reach all users based on their visit frequency.