How to Handle Service Worker Updates Without Breaking Push

Updating a service worker without coordination silently breaks active push subscriptions. When a new worker enters installing, existing push subscriptions remain cryptographically bound to the currently active worker. Invoking self.skipWaiting() before the new worker has registered its push event listener causes push events to arrive in an unhandled context, resulting in silent delivery failures that are invisible to the sender.

The fix is a deliberate activation handshake: defer skipWaiting() until the new worker is fully initialized, handle pushsubscriptionchange before claiming clients, and validate subscription state post-activation.

The Subscription–Lifecycle Binding Model

Each push subscription is cryptographically tied to the VAPID public key used at pushManager.subscribe() time and to the service worker registration scope. When the browser detects a byte-changed worker script, it enters a three-phase transition:

  1. installing — new script is parsed and evaluated; install event fires
  2. waiting — new worker is ready but defers to the currently active one
  3. activatingactivated — new worker takes control after skipWaiting() or all controlled tabs close

Push events dispatched during the gap between a stale active worker being displaced and the new worker completing activate arrive in a context with no push listener, failing silently. Mapping this transition correctly is the foundational requirement of the Service Worker Registration Patterns guide before modifying any activation logic.

Service worker update lifecycle and push subscription checkpoints Sequence showing the installing, waiting, activating, and activated states with push event listener registration and pushsubscriptionchange handling checkpoints marked. installing install event waiting skipWaiting() gate activating clients.claim() activated push events safe Register push listener before install resolves User-gated skipWaiting never auto on deploy Handle subscription pushsubscriptionchange Validate subscription pushManager.getSubscription Push events arriving during the waiting→activating gap fail silently if listeners are not pre-registered.
Service worker update lifecycle with push subscription safety checkpoints: push listeners must be registered before the install event resolves, and pushsubscriptionchange must be handled before clients.claim().

Diagnostic Workflow: Isolating Silent Push Failures

Before changing activation logic, confirm where failures originate.

  1. Monitor controller state during update. A null navigator.serviceWorker.controller after a page reload indicates the new worker has not yet claimed the page.
  2. Trace the push event listener. Add structured logging to the push handler immediately — if it never logs on a test push, the listener is not registered at activation time.
  3. Cross-reference updatefound and pushsubscriptionchange timing. A pushsubscriptionchange event firing before the new worker’s activate handler completes means subscription renewal ran before clients.claim() — subscriptions may reference the old endpoint in your backend.
  4. Attach a controller-change observer. Capture activation transitions and validate the subscription immediately after:
navigator.serviceWorker.addEventListener('controllerchange', () => {
  console.info('[SW] Controller changed — verifying push subscription integrity...');
  navigator.serviceWorker.ready.then(reg =>
    reg.pushManager.getSubscription().then(sub => {
      if (!sub) {
        console.warn('[SW] No active push subscription after controller change.');
      } else {
        console.info('[SW] Push subscription intact:', sub.endpoint.slice(0, 60) + '...');
      }
    })
  );
});
  1. Check subscription endpoint against your backend. Query your subscription store to verify the endpoint recorded matches (await reg.pushManager.getSubscription()).endpoint after the controller change. Stale endpoints cause silent delivery failures regardless of worker state.

Step-by-Step Implementation: Safe Update Protocol

Step 1 — Pre-Register Push Listeners Before the Install Event Resolves

Push event listeners must be registered synchronously at the top level of the service worker script, before any await or dynamic import. The browser gates push event dispatch on activated state, but the listener binding must exist before the install promise resolves to avoid a registration gap.

// sw.js — listeners are registered at parse time, not inside install
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: 'Notification', body: '' };
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/assets/icon-192.png',
      tag:  data.tag ?? 'default'
    })
  );
});

Step 2 — Handle pushsubscriptionchange Before Claiming Clients

The pushsubscriptionchange event fires when the push service rotates or invalidates a subscription endpoint — it can arrive during the activation window. Handle it inside the service worker so re-subscription completes before new pages are claimed.

// sw.js — VAPID public key injected at build time as a constant
// In production, replace this string via your bundler or environment substitution.
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY ?? 'REPLACE_AT_BUILD_TIME';

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64  = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from(rawData, c => c.charCodeAt(0));
}

self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil((async () => {
    try {
      const subscription = await self.registration.pushManager.subscribe({
        userVisibleOnly:      true,
        applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
      });

      // Persist the renewed subscription to your backend
      await fetch('/api/push/subscription', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify(subscription.toJSON())
      });
    } catch (err) {
      console.error('[SW] Subscription renewal failed:', err);
      // The browser retries pushsubscriptionchange on the next push event
    }
  })());
});

For key rotation scenarios where VAPID_PUBLIC_KEY changes, see Rotating VAPID Keys Without Losing Subscribers — the same pushsubscriptionchange handler is the re-subscription mechanism for both update-triggered and key-rotation-triggered endpoint changes.

Step 3 — Gate skipWaiting() on an Explicit User Signal

Never call self.skipWaiting() unconditionally in the install handler. Doing so displaces the active worker immediately on every deploy, creating the push event gap described above.

// sw.js — only skip waiting when the client explicitly requests it
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

self.addEventListener('activate', (event) => {
  // clients.claim() in activate (not install) ensures the new worker
  // is fully initialized before taking control of existing pages.
  event.waitUntil(clients.claim());
});

Step 4 — Trigger the Update from the Client After User Confirmation

Show a non-intrusive banner (“Update available — reload to apply”) and send SKIP_WAITING only after the user acknowledges it, or after a confirmed idle timeout.

// client.js — check for a waiting worker and prompt the user
async function promptAndUpdate() {
  const reg = await navigator.serviceWorker.getRegistration();
  if (!reg?.waiting) return;

  // Show a UI prompt — omitted here for brevity; use your design system's banner
  const userConfirmed = await showUpdateBanner('A new version is available.');
  if (userConfirmed) {
    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
  }
}

// After the new worker takes control, reload so the page uses the new worker
navigator.serviceWorker.addEventListener('controllerchange', () => {
  window.location.reload();
});

// Check on every page focus — a deploy may have happened while the tab was idle
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') promptAndUpdate();
});

Step 5 — Validate Subscription Survival Post-Activation

After the controller changes, confirm the subscription is still intact before sending a test push.

navigator.serviceWorker.ready.then(async (reg) => {
  const sub = await reg.pushManager.getSubscription();
  if (sub) {
    console.info('[App] Push subscription active post-update:', sub.endpoint.slice(0, 60));
  } else {
    console.warn('[App] Push subscription lost after update — re-subscribing...');
    // Trigger your normal subscription flow here
  }
});

Configuration Constraints

Setting Correct Behavior Common Mistake
self.skipWaiting() placement Inside message handler, gated on user signal Unconditionally inside install handler
clients.claim() placement Inside activate handler Inside install handler — worker not yet initialized
event.waitUntil() usage Wraps every async operation in push, pushsubscriptionchange, activate Omitted — browser can throttle or kill the worker mid-operation
updateViaCache on registration 'none' — always fetch latest worker script Default 'imports' — can serve stale worker from HTTP cache
Push listener registration Top-level synchronous, before any await Inside install.waitUntil() — misses events during activation gap

Cross-Browser Validation Notes

Push delivery behavior during service worker updates differs by engine.

  • Chromium: Aggressively caches push endpoints. Confirm pushsubscriptionchange fires after endpoint rotation by inspecting FCM responses. Chromium may throttle push delivery to background tabs on battery-saving devices — always use event.waitUntil() to extend the execution budget.
  • Firefox: Silently fails if the pushsubscriptionchange handler omits event.waitUntil(). Mozilla’s autopush service fires this event more frequently than FCM during key rotation scenarios. Validate on Firefox specifically after any VAPID key rotation.
  • Safari (iOS 16.4+ / macOS 13+): Requires explicit user gesture history before Notification.requestPermission() succeeds. Background worker activation restrictions are tighter than Chromium. Test subscription renewal on real devices, not simulators.

The cross-browser notification quirks reference covers engine-specific delivery constraints beyond the update lifecycle.

Gotchas & Edge Cases

  • clients.claim() in install breaks push event routing. If you call clients.claim() before activate, the new worker takes control of pages that still expect the old worker’s handler signature — incoming push events may match no listener.
  • pushsubscriptionchange does not fire on all browsers after a VAPID key change. On some Chromium versions the event is suppressed; the subscription simply expires and future pushes fail with 410 Gone. Design your backend to handle 410 responses and trigger re-subscription through your normal subscription flow, not only through pushsubscriptionchange. See handling 410 Gone responses at scale.
  • Payload encryption key mismatch during overlap window. If two workers coexist briefly and the server sends payloads encrypted with a key registered under the new worker while the old worker is still active, decryption fails with AbortError. Use push API payload encryption best practices to align encryption keys with the active subscription endpoint.
  • window.location.reload() on controllerchange causes infinite loops if the activation is triggered by a navigation. Guard the reload with a flag: set sessionStorage.swUpdated = true before reloading, and skip reload if it is already set.
  • PushManager.subscribe() inside pushsubscriptionchange can throw NotAllowedError. This happens if the user has revoked notification permission since the original subscription. Catch the error separately from network failures — NotAllowedError requires user intervention and should not be retried automatically.

Back to Service Worker Registration Patterns

FAQ

Is it safe to call self.skipWaiting() in the install handler?

No. Calling self.skipWaiting() unconditionally in install displaces the currently active worker the moment the new script finishes installing — before the new worker has claimed clients and before your push event listener is confirmed active. Push events dispatched in that gap have no handler and fail silently. Gate skipWaiting() on an explicit message from the client (e.g., { type: 'SKIP_WAITING' }) sent after user confirmation.

What happens to active push subscriptions when the service worker updates?

Push subscriptions survive service worker updates intact — the subscription is bound to the push service endpoint and the VAPID public key, not to the worker script version. What breaks is event handling, not the subscription itself: if the new worker displaces the old one before registering its push listener, incoming push events arrive in a context with no handler. The subscription is still valid; only the event routing breaks. Handle pushsubscriptionchange in the new worker before clients.claim() to catch any endpoint rotation that coincides with the update.

How do I test that push works correctly after a service worker update?

Deploy the updated service worker, trigger the update flow in a controlled test environment, and send a test push notification before reloading any tabs. Verify three things: (1) the controllerchange event fires on the client, (2) pushManager.getSubscription() returns a non-null subscription after the change, and (3) a push notification dispatched from your server is received and displayed. Use the controllerchange listener pattern from Step 4 above as the integration test hook. For Firefox, explicitly verify pushsubscriptionchange fires by inspecting about:serviceworkers in the developer console.