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://, orhttp://localhostfor local development).process.env.VAPID_PRIVATE_KEY).NotificationandPushManagerfeature 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: installing → installed/waiting → activating → activated/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.
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.readybefore anyPushManagercalls. - Mobile Background Limits: Design payloads to be stateless. Assume the worker may be terminated by the OS before
showNotification()resolves; always callevent.waitUntil(). - Missing
controllerchangeListeners: Attach globally to prevent inconsistent state after worker updates.
Related
- How to handle service worker updates without breaking push — safe deploy orchestration for an installed base.
- Why pushManager.subscribe fails with NotAllowedError — every cause of the subscription rejection.
- Push API Payload Encryption — the decrypted payload your worker receives.
- VAPID Key Generation & Rotation — the public key passed to
applicationServerKey. - Cross-Browser Notification Quirks — engine differences in wake-up and rendering.
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.