Service Worker Registration Patterns

The service worker is the mandatory execution layer between the browser’s push service and your application logic — every push event, decryption, and showNotification() call runs inside it. This guide covers registration, scope, the lifecycle state machine, and safe update orchestration so subscriptions survive deploys. For the full stack it sits within, start from Core Protocols & Browser Implementation.

Prerequisites

  • https://, or http://localhost for local development).
  • process.env.VAPID_PRIVATE_KEY).
  • Notification and PushManager feature detection in place before any registration call.
  • Push API Payload Encryption flow, since the worker receives the decrypted payload.

Architectural Context & Registration Fundamentals

The service worker operates as the mandatory execution layer between browser push services and your application logic. It intercepts incoming push events, manages background synchronization, and serves as the cryptographic endpoint for incoming push messages. Without a stable registration, downstream push operations cannot execute, rendering subscription lifecycles inert.

Registration establishes the worker’s operational scope and initiates the browser’s lifecycle state machine: installinginstalled/waitingactivatingactivated/controlling. Scope boundaries dictate which URLs the worker can intercept. Misaligned scopes cause push events to route to inactive contexts or fail silently, breaking notification delivery.

Service worker lifecycle state machine A registered worker moves from installing to installed/waiting, then activating to activated/controlling. A waiting worker only takes control after all clients close, or immediately when skipWaiting and clients.claim are called. installing install event installed waiting activating activate event activated controlling all clients closed — or skipWaiting() clients.claim() makes the activated worker control open pages immediately
The lifecycle state machine: a waiting worker normally activates only after all clients close, unless skipWaiting() and clients.claim() force immediate control.
navigator.serviceWorker.register('/push-sw.js', {
  scope: '/',
  updateViaCache: 'none'
}).catch(err => {
  console.error('Service worker registration failed:', err);
  // Fallback to polling or deferred registration strategy
});

The updateViaCache: 'none' directive ensures the browser fetches the latest worker script on every navigation, preventing stale handlers from caching outdated routing logic. This foundational step operationalizes the broader protocol stack detailed in Core Protocols & Browser Implementation, where vendor-specific guarantee models and network fallback behaviors are standardized — and catalogued by version in the browser compatibility reference.

Scope Management & Secure Subscription Handoff

Optimal scope configuration prevents silent push event failures and ensures cryptographic handoffs occur within trusted contexts. The transition from navigator.serviceWorker.ready to PushManager.subscribe() requires strict sequencing to avoid race conditions.

Registration readiness must resolve before invoking subscription methods. Concurrent calls during page load or background tab activation frequently trigger InvalidStateError or return null subscriptions. Always gate subscription calls on navigator.serviceWorker.ready.

async function initializePushSubscription(applicationServerKey) {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    return null;
  }

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  // Wait for the service worker to be fully active before subscribing
  const reg = await navigator.serviceWorker.ready;

  // Check for existing subscription to prevent duplicate endpoints
  let subscription = await reg.pushManager.getSubscription();
  if (subscription) return subscription;

  subscription = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: applicationServerKey // Uint8Array of VAPID public key
  });

  return subscription;
}

The applicationServerKey parameter must be a Uint8Array derived from your VAPID public key (base64url-decoded). Injecting this key during the subscribe() handshake binds the push endpoint to your origin’s cryptographic identity. Key rotation strategies and endpoint invalidation workflows are covered in VAPID Key Generation & Rotation, ensuring seamless credential transitions without requiring user re-subscription. When subscribe() rejects with a NotAllowedError, the cause is permission or gesture state rather than the key itself — the dedicated guide on why pushManager.subscribe fails with NotAllowedError enumerates each trigger.

Registration Options Reference

Option Type Default Notes
scope string script directory Cannot exceed the script’s path; register at / or /push/.
updateViaCache string 'imports' Set 'none' so the worker script bypasses the HTTP cache.
type string 'classic' Use 'module' only if the script uses ES module import.
userVisibleOnly boolean Required true on Chromium; every push must show a notification.
applicationServerKey Uint8Array/string base64url-decoded VAPID public key; binds endpoint to your origin.

Event Routing & Decryption Pipeline

Once registered, the worker intercepts push events from the browser’s native push service. The browser automatically decrypts the aes128gcm ciphertext before delivering the payload to the service worker — event.data already contains the plaintext. The worker must parse and validate the data, then route it to a notification or background update.

self.addEventListener('push', (event) => {
  const processPush = async () => {
    let data;
    try {
      data = event.data ? event.data.json() : {};
    } catch {
      // Malformed JSON: show a generic notification rather than failing silently
      data = { title: 'Notification', body: 'You have a new message.' };
    }

    await self.registration.showNotification(data.title, {
      body:    data.body,
      icon:    '/assets/notification-icon.png',
      tag:     data.tag     || 'default',
      renotify: data.renotify ?? false
    });
  };

  event.waitUntil(processPush());
});

A subtle but important point: event.data.json() can throw, and an uncaught throw inside the push handler still counts as a push that produced no notification, which is exactly what userVisibleOnly: true penalizes. The pattern above swallows the parse error and shows a generic notification precisely so that no parsing failure can ever leave the event silent. The same discipline applies to any async work — a fetch to hydrate the notification body, an IndexedDB write — every branch must terminate in a showNotification() call and the whole chain must be inside event.waitUntil() so the browser keeps the worker alive until it resolves. Workers are aggressively terminated between events; never rely on module-scope variables surviving from one push to the next, because the runtime may have torn the worker down and rebuilt it in between.

Route non-UI payloads (e.g., analytics pings, cache updates) to BackgroundSync or IndexedDB to maintain compliance with userVisibleOnly: true mandates — Chromium requires every push event to result in a visible notification unless you have a specific exemption.

The cryptographic validation and payload parsing workflows are standardized in Push API Payload Encryption, which details the aes128gcm record format, padding schemes, and the browser-specific payload size thresholds. Engine-specific rendering differences in the resulting notification are covered in Cross-Browser Notification Quirks.

Versioning, Updates & Lifecycle Control

Updating registered workers without dropping active push subscriptions is a critical production requirement. Browsers queue new worker scripts in a waiting state until all controlled clients are closed or explicitly claimed. Aggressive update strategies can orphan subscription endpoints.

// In the service worker: activate immediately when signaled
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim());
});
// Client-side: prompt the user before activating the waiting worker
async function applyUpdate() {
  const reg = await navigator.serviceWorker.getRegistration();
  if (reg?.waiting) {
    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
  }
}

navigator.serviceWorker.addEventListener('controllerchange', () => {
  window.location.reload();
});

The clients.claim() method forces the newly activated worker to take control of existing pages immediately. The controllerchange listener ensures clients synchronize with the updated worker. Without it, tabs may continue executing stale routing logic, causing notification mismatches or failed push event handling. Comprehensive update orchestration, including queue draining and backward-compatible payload migration, is documented in how to handle service worker updates without breaking push.

The push subscription survives a worker script update — it is bound to the registration and the VAPID public key, not to a particular version of the script — so a routine deploy does not require re-subscription. The danger is not the subscription but the handler: if your new push listener parses a payload shape the old one did not produce, in-flight messages queued under the old contract can render incorrectly during the rollover. Version your payload schema and have the worker accept both the previous and current shapes for at least one TTL window after a deploy. Calling skipWaiting() unconditionally on every load is an anti-pattern for the same reason — it can swap the controller out from under an open page mid-interaction; gate it behind a user-visible “update available” prompt and only then post the SKIP_WAITING message.

Verification

Confirm registration, scope, and event routing before shipping a worker change.

# In Chrome DevTools console, inspect the active registration and its scope
# navigator.serviceWorker.getRegistration().then(r => console.log(r.scope, r.active?.state));

Open chrome://serviceworker-internals to see the worker’s state (activated), running status, and the last push event. In the DevTools Application panel, the Service Workers pane shows the waiting vs active script and lets you push a test message. A correctly registered worker reports its scope, shows activated, and fires the push handler with non-null event.data.

Error & Edge-Case Matrix

Condition Cause Fix
Registration rejects Script not on secure origin or 404 at scope path Serve over HTTPS; place script at/above the scope
InvalidStateError on subscribe Called before serviceWorker.ready resolved Await navigator.serviceWorker.ready first
NotAllowedError on subscribe Permission denied or no user gesture Request inside a click handler; see the NotAllowedError guide
Push events never fire Scope does not cover the controlled pages Register at / or a dedicated /push/ path
Stale handler after deploy New worker stuck in waiting Prompt user, post SKIP_WAITING, reload on controllerchange
Worker killed before notification Async work not wrapped Always call event.waitUntil()
Duplicate endpoints Subscribing without checking getSubscription() Reuse existing subscription when present

Cross-Browser Constraints & Telemetry

Registration behavior diverges across Chromium, WebKit, and Gecko engines. Chromium enforces strict background execution budgets and throttles push delivery on low-power mobile devices. WebKit requires explicit user interaction to trigger Notification.requestPermission() and restricts background worker wake-ups. Gecko maintains broader background execution windows but enforces stricter payload size validation.

Production Monitoring Checklist

  • Track registration success rate and subscription endpoint generation latency via RUM telemetry.
  • Monitor push event delivery failures segmented by OS, browser version, and network type.
  • Implement exponential backoff for failed subscribe() retries to avoid rate-limiting by push gateways; the retry logic & backoff strategies guide formalizes the schedule.
  • Fall back to email or in-app messaging for mobile-web sessions where background execution is OS-throttled.

Common Pitfall Mitigations

  • Scope Misalignment: Always register at the application root (/) or a dedicated /push/ path. Sub-scopes fragment push routing.
  • Aggressive skipWaiting(): Never invoke without client notification. Queue updates and prompt users during idle states.
  • Stale Caching: Set updateViaCache: 'none' to bypass HTTP cache headers that delay worker updates.
  • Readiness Race Conditions: Await navigator.serviceWorker.ready before any PushManager calls.
  • Mobile Background Limits: Design payloads to be stateless. Assume the worker may be terminated by the OS before showNotification() resolves; always call event.waitUntil().
  • Missing controllerchange Listeners: Attach globally to prevent inconsistent state after worker updates.

Back to Core Protocols & Browser Implementation

FAQ

Where should I register the service worker for push?

Register at the application root (/) or a dedicated /push/ path so the worker’s scope covers every page that needs push routing. The scope can never be broader than the script’s own path, so place push-sw.js at the site root if you want root scope. Sub-scopes fragment routing and cause silent push failures.

Why does pushManager.subscribe() return null or throw InvalidStateError?

This happens when subscribe() runs before the worker is active. Always await navigator.serviceWorker.ready first, and check getSubscription() to reuse an existing endpoint. A NotAllowedError is different — it means permission was denied or there was no user gesture, covered in the NotAllowedError guide.

How do I update a worker without breaking existing subscriptions?

A new worker enters the waiting state and only takes over when all clients close. Post a SKIP_WAITING message after prompting the user, call clients.claim() in the activate handler, and reload on controllerchange. The push subscription itself is unaffected by a script update as long as the VAPID public key is unchanged.

Does the service worker decrypt the push payload?

No. The browser decrypts the aes128gcm record before dispatching the push event, so event.data already contains plaintext. Your worker only needs to parse and validate it. The encryption itself happens server-side, as detailed in the payload encryption guide.

Why must every push event show a notification?

Under userVisibleOnly: true, Chromium requires each push to produce a visible notification; repeatedly suppressing notifications can revoke the origin’s push permission. Route non-UI work to BackgroundSync or IndexedDB, but always call showNotification() for user-facing pushes, and wrap async work in event.waitUntil() so the worker is not killed early.