EdgeCases Logo
Feb 2026
Browser APIs
Deep
7 min read

Service Worker Lifecycle: Why Your PWA Update Isn't Working

skipWaiting() doesn't mean instant. clients.claim() has timing gotchas. First visits don't use your SW. Master the edge cases to build reliable PWAs.

service-worker
pwa
caching
lifecycle
browser-apis
edge-case

Your Service Worker update isn't working. Users complain about stale content. You've read the docs, called skipWaiting(), and still—nothing. The Service Worker lifecycle is deceptively complex, with edge cases that catch even experienced developers.

The Lifecycle, Quickly

Service Workers move through states: installing → waiting → activating → activated. The critical insight: a new SW won't activate until all tabs using the old SW are closed.

// Registration triggers the lifecycle
navigator.serviceWorker.register('/sw.js');

// Service Worker states
'installing'  → downloading/parsing
'installed'   → waiting for old SW to release control
'activating'  → taking over, running activate event
'activated'   → controlling pages, handling fetch events

Edge Case 1: skipWaiting() Doesn't Mean Instant

skipWaiting() skips the waiting phase, but it doesn't make the new SW control pages immediately:

// In service-worker.js
self.addEventListener('install', (event) => {
  self.skipWaiting();  // Skip waiting phase
});

// What happens:
// 1. New SW installs
// 2. skipWaiting() → immediately moves to 'activating'
// 3. Activate event runs
// 4. SW is 'activated'...
// 5. BUT existing pages still controlled by OLD SW until refresh!

The new SW is active, but pages opened before the update still use the old one. They won't switch until they reload or navigate away.

Edge Case 2: clients.claim() Timing

clients.claim() makes the activated SW take control of existing pages without requiring a refresh:

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

// Now existing pages get the new SW immediately

But there's a catch: if you call clients.claim() in the activate event without event.waitUntil(), the SW might deactivate before claim completes:

// ❌ Race condition
self.addEventListener('activate', (event) => {
  clients.claim();  // Might not complete!
});

// ✅ Proper usage
self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});

Edge Case 3: First Visit Doesn't Use SW

On a user's first visit, the Service Worker installs but doesn't control the page:

// First visit timeline:
1. User visits site for the first time
2. Page loads from network (no SW yet)
3. SW registers and installs
4. SW activates
5. Page is NOT controlled by SW (it loaded before SW existed)
6. User refreshes → NOW the page is controlled

Without clients.claim(), the first page load never benefits from the SW. This is why you see "refresh for offline support" messages.

// To control first-visit pages:
self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
  // First-visit pages now controlled
});

Edge Case 4: The Dangerous skipWaiting + claim Combo

Combining skipWaiting() and clients.claim() can break your app:

// sw-v1.js caches /app.js (version 1)
// User has tab open with app.js v1 loaded

// sw-v2.js deploys with skipWaiting + claim
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(clients.claim()));

// What happens:
// 1. sw-v2 installs, skipWaiting → activates immediately
// 2. clients.claim() → takes control of existing tab
// 3. Tab now fetches through sw-v2's cache
// 4. User clicks button that fetches /api/data
// 5. sw-v2 returns /api/data from its cache (or new network response)
// 6. BUT the page is still running app.js v1!
// 7. If APIs changed between v1→v2, app breaks

The page HTML/JS is from v1, but fetch requests go through v2. This mismatch causes subtle bugs that are hard to reproduce and debug.

Safe Update Pattern

// Option 1: Prompt user to refresh
navigator.serviceWorker.addEventListener('controllerchange', () => {
  // New SW took control
  if (confirm('New version available. Reload?')) {
    window.location.reload();
  }
});

// Option 2: Only skipWaiting when user consents
// In your page:
function applyUpdate() {
  navigator.serviceWorker.ready.then((registration) => {
    if (registration.waiting) {
      registration.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  });
}

// In service-worker.js:
self.addEventListener('message', (event) => {
  if (event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Edge Case 5: Scope Conflicts

Service Workers control pages within their scope. Nested scopes create hierarchy:

// Two SWs registered:
navigator.serviceWorker.register('/sw.js');           // Controls /
navigator.serviceWorker.register('/blog/sw.js');      // Controls /blog/

// /blog/post/1 → controlled by /blog/sw.js (more specific)
// /about       → controlled by /sw.js
// /blog        → controlled by /blog/sw.js

The gotcha: if /blog/sw.js unregisters, pages under /blog/ aren't automatically controlled by /sw.js—they become uncontrolled until refresh.

Edge Case 6: Update Check Timing

The browser checks for SW updates on navigation, but with limits:

// Update checks happen:
// - On navigation to a controlled page
// - On functional events (push, sync) if >24h since last check
// - When registration.update() is called manually

// But NOT:
// - While a fetch event is being handled
// - If checked <24 hours ago (browser-dependent)
// - If the page is a soft navigation (SPA route change)

For SPAs, manually trigger update checks on route changes:

// In your SPA router
router.afterEach(() => {
  navigator.serviceWorker.ready.then((registration) => {
    registration.update();
  });
});

Edge Case 7: Byte-for-Byte Comparison

Browsers compare the new SW file byte-for-byte with the cached one. A single byte difference triggers an update:

// sw.js - Adding a version comment forces update
const VERSION = 'v2.0.1';  // Change this to force update

// Or use build hash in filename
// sw.abc123.js → sw.def456.js

Gotcha: if your build process minifies differently each time (whitespace, variable names), you might trigger unnecessary updates. Use consistent build output or explicit versioning.

Edge Case 8: importScripts Caching

Files loaded with importScripts() are cached separately:

// sw.js
importScripts('/sw-utils.js');

// If sw-utils.js changes but sw.js doesn't,
// the browser might not detect an update!

// Solution: Include version in imported file names
importScripts('/sw-utils.v2.js');

// Or reference a hash
importScripts('/sw-utils.abc123.js');

Debugging Service Workers

// Check SW state
navigator.serviceWorker.ready.then((reg) => {
  console.log('Active SW:', reg.active?.state);
  console.log('Waiting SW:', reg.waiting?.state);
  console.log('Installing SW:', reg.installing?.state);
});

// Listen for state changes
navigator.serviceWorker.addEventListener('controllerchange', () => {
  console.log('Controller changed!');
});

// In DevTools:
// Application → Service Workers → "Update on reload" for development

Key Takeaways

  • skipWaiting() activates the SW but doesn't control existing pages—add clients.claim()
  • The skipWaiting + claim combo can cause version mismatches—prompt users to reload instead
  • First-visit pages aren't controlled unless you use clients.claim()
  • SPAs need manual registration.update() calls on route changes
  • Changes to imported scripts might not trigger updates—version your imports
  • Use "Update on reload" in DevTools during development to avoid lifecycle headaches

Advertisement

Related Insights

Explore related edge cases and patterns

React
Deep
React useId: Why Math.random() Breaks Hydration
7 min
JavaScript
Deep
Promise.withResolvers(): The Pattern You've Been Hacking Around
7 min

Advertisement