OpenPWAStore
Back to News
Guide · May 20, 2026

Service Worker skipWaiting requires update discipline, not instant reloads

Force-activating a new service worker can cause data loss. Here's how to do it safely.

OpenPWA Editorial6 min read
Service Worker skipWaiting requires update discipline, not instant reloads cover

Why skipWaiting() Matters for PWA Updates

Service workers have a lifecycle that intentionally delays new versions from taking over: an installing worker becomes waiting only after it finishes installing, and users must close all tabs or manually trigger activation for it to become active.

This design prevents abrupt mid-session breakage, but it also slows down urgent or low-risk updates. The skipWaiting() method bridges this gap by forcing the waiting worker to activate immediately.

MDN's service worker lifecycle documentation explains that skipWaiting() is not automatic—it requires a deliberate call, often paired with user notice.

How the Service Worker Lifecycle Works

Three States

  1. Installing: The worker is downloading and parsing assets. Scripts may self.skipWaiting() here.
  2. Waiting: The worker has installed successfully but is waiting for all tabs to close before activating.
  3. Active: The worker is controlling pages and handling fetch events.

Automatic vs. Manual Activation

  • Manual default: Users must close all open tabs of your origin for the waiting worker to activate.
  • skipWaiting(): Forces activation immediately, even with open tabs.

Implementing skipWaiting() Correctly

Step 1: Call skipWaiting() in the Worker

// sw.js
self.addEventListener('install', (event) => {
  // Pre-cache critical assets...
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll([
        '/',
        '/offline.html',
        '/styles.css',
      ]);
    })
  );
});

// The activation behavior
self.addEventListener('activate', (event) => {
  // Clean up old caches
  event.waitUntil(
    caches.keys().then((cacheKeys) => {
      return Promise.all(
        cacheKeys
          .filter((key) => key.startsWith('v') && key !== 'v1')
          .map((key) => caches.delete(key))
      );
    })
  );
});

// If you want immediate activation (user-controlled):
self.addEventListener('install', (event) => {
  const skipWaitingPromise = self.skipWaiting();
  event.waitUntil(skipWaitingPromise);
});

Critical: Always call skipWaiting() inside an event listener (typically install), wrapped in event.waitUntil(). Never call it arbitrarily during fetch handling or other events.

Step 2: Notify Users in the Client

if ('serviceWorker' in navigator) {
  let registration;

  navigator.serviceWorker.register('/sw.js').then((reg) => {
    registration = reg;

    // Listen for a new worker entering the waiting state
    reg.addEventListener('updatefound', () => {
      const newWorker = reg.installing;

      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // A new version is waiting
          showUpdateAvailable();
        }
      });
    });
  });
}

function showUpdateAvailable() {
  const updateButton = document.createElement('button');
  updateButton.textContent = 'Update now';
  updateButton.onclick = () => {
    if (registration && registration.waiting) {
      // Post a message to the waiting worker to skip waiting
      registration.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  };

  // Show a non-intrusive toast/banner
  document.body.appendChild(createUpdateUI(updateButton));
}

// Listen for controller change (the worker activated)
navigator.serviceWorker.addEventListener('controllerchange', () => {
  // Ideally, offer a "Refresh" option or auto-reload after user confirms
  window.location.reload();
});

Step 3: Handle skipWaiting Message in the Worker

// sw.js
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Why a message? Calling skipWaiting() directly from the client isn't possible—you must send a message to the worker.

Common Pitfalls

1. Calling skipWaiting() Automatically Without User Notice

Calling skipWaiting() for every update can:

  • Break mid-session form submissions or transactions
  • Invalidate client-side state (indexedDB, local storage assumptions)
  • Cause layout shifts or errors with race conditions

Guideline: Only skip for low-risk updates (minor CSS tweaks, HTML template changes) or with explicit user consent.

2. Failing to Clean Up Old Caches

When a new worker activates, you must remove cache entries from previous versions to avoid storage bloat and stale artifacts:

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== currentCacheName) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

3. Race Conditions During Activation

If clients don't reload immediately after controllerchange, they may be controlled by the new worker but still hold onto old assumptions:

  • Workaround: The safest approach is to reload or offer a "Reload" prompt immediately.

4. Ignoring naviationPreload

For PWAs heavily dependent on network responses, skipWaiting() without co-ordinating navigation preload can cause duplicate requests or failed handling:

// sw.js
navigationPreload.enable('/lookup?src=' + self.registration.scope);

Testing with navigator.serviceWorker.ready in clients reduces race risk.

##-Type-Based Update Strategies

| Update Type | Use skipWaiting()? | Rationale | |-------------|-------------------|-----------| | Critical bug fix | ✅ | Forcing immediately improves user experience | | Low-risk CSS/HTML tweak | ✅ | Breaking risk is minimal | | JavaScript logic change | ⚠️ | May break in-flight operations | | Asset bundle change (JS/CSS) | ✅ | Users benefit immediately; coordinates via cache names | | IndexedDB schema change | ⚠️ | Ensure migration path | | Offline strategy shift | ⚠️ | Test thoroughly before skipping |

User Experience Patterns

Pattern 1: "Update Available" Toast

function createUpdateToast() {
  const toast = document.createElement('div');
  toast.className = 'update-toast';
  toast.innerHTML = `
    <span>A new version is available.</span>
    <button id="updateNow">Refresh</button>
  `;
  document.body.appendChild(toast);

  toast.querySelector('#updateNow').onclick = () => {
    if (registration.waiting) {
      registration.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  };
}

// Show only once per session
const updateShown = sessionStorage.getItem('updateShown');
if (!updateShown) {
  sessionStorage.setItem('updateShown', 'true');
  createUpdateToast();
}

Pattern 2: In-App Update Banner

For content-focused PWAs:

  • Add a small banner at the top/bottom
  • Include "Dismiss" and "Update" buttons
  • Persist dismissal via LocalStorage

Pattern 3: Silent Refresh for Low-Risk Updates

For non-transactional apps:

if (registration.waiting && currentVersion === 'newStableVersion') {
  registration.waiting.postMessage({ type: 'SKIP_WAITING' });
  // Allow clients to reload automatically after controllerchange
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    window.location.reload();
  });
}

When to avoid: E-commerce, banking, form-heavy apps.

Verification Checklist

Before deploying automatic skipWaiting():

Worker Side

  • [ ] skipWaiting() called inside an event listener and wrapped in event.waitUntil()
  • [ ] Message handler listens for SKIP_WAITING via postMessage()
  • [ ] activate event cleans up old caches
  • [ ] Cache names are versioned (v1, v2) to distinguish assets

Client Side

  • [ ] updatefound listener detects new workers
  • [ ] User is notified via non-intrusive UI (toast, banner)
  • [ ] postMessage to waiting worker when user accepts update
  • [ ] controllerchange listener triggers page reload or informs users manually

Testing

  • [ ] Test with open tabs: Verify controllerchange fires without requiring tab closure
  • [ ] Test cache cleanup: Confirm old versions don't accumulate
  • [ ] Test mid-session form submission: Ensure data isn't lost during update
  • [ ] Test offline mode: Verify new version caches correctly and works offline

For Different App Types

E-Commerce

  • ❌ Avoid automatic skipWaiting.
  • ✅ Use user-controlled refresh with cart preservation.
  • ✅ Coordinated indexedDB migrations.

Content/News

  • ✅ Aggressive updates improve freshness.
  • ✅ Use silent refresh for low-risk changes.
  • ⚠️ Be mindful of reading state (article scroll position).

Productivity/Tools

  • ⚠️ Updates often affect logic; require user confirmation.
  • ✅ Preserve document state (e.g., draft content) across reload.

Gaming

  • ⚠️ Update mid-session harmful; require session-end reload.
  • ✅ Use "Update ready, play current game" messaging.

Browser Variations

  • Chrome: skipWaiting() widely supported; inspect behavior in DevTools Application tab.
  • Edge:chromium-based; matches Chrome.
  • Firefox: Supports skipWaiting; but some devcache quirks exist.
  • Safari: Service workers available, but activation lifecycle differs slightly; test thoroughly.

Tools and Debugging

DevTools Service Worker Panel

  • Inspect installing, waiting, and active workers.
  • Trigger skipWaiting manually via the UI for testing.
  • View logs from the worker via console.log().

Workbox Guidance

If using Workbox, their skipWaiting() setup simplifies lifecycle handling:

workbox.core.clientsClaim();
workbox.precaching.precacheAndRoute([...]);

// In the worker:
self.addEventListener('install', (event) => {
  event.waitUntil(self.skipWaiting());
});

Workbox's clientsClaim() also helps pages be controlled immediately after activation.

Next Steps

  1. Audit your current service worker update flow: Is it manual or automatic?
  2. Determine your app's risk profile: How many mid-session operations break?
  3. Design an update UX pattern: Toast? Banner? In-app modal?
  4. Implement the message-passing pattern between client and worker.
  5. Test on real devices with multiple tabs open.

The goal isn't speed alone—it's delivering updates when they matter without disrupting user work. skipWaiting() is a tool, not a default.