Service Worker Update Patterns Compared¶
When you deploy a new service worker, the browser installs it next to the old one and then has to decide when the new version takes over the pages that are already open. There are five established ways to handle that moment: wait for every tab to close, activate immediately with skipWaiting(), ask the user to reload, switch at the next in-app navigation, or activate and reload automatically. Each trades update speed against the risk of running old page code against a new worker. This post compares all five, puts them through the same deploy scenarios, and ends with a decision table and a hybrid policy that lets each release choose its own pattern. The step-by-step implementation of each pattern lives on Updating Service Workers.
Key takeaways
- Every pattern is a different answer to one question: how long may an old page run against a new worker and its caches? That window is the version skew each pattern accepts.
- The default wait has zero skew but can leave users on an old version for days, because a reload doesn't activate a waiting worker and installed apps rarely close all their windows.
skipWaiting()on its own is only safe for workers that don't precache versioned code, or that keep serving the previous version's assets until old pages are gone.- The prompt and next-navigation patterns are the right default for app shells. The Navigation API, now in Chrome 102, Firefox 147 and Safari 26.2, lets you apply an update at the next route change without touching your router.
- Auto-reload belongs on screens without user input, and always needs a loop guard.
- You don't have to pick one pattern forever. Let each release declare its urgency and minimum compatible page build, and let the page choose the pattern at update time.
What an update pattern decides¶
The mechanics are covered in depth on Updating Service Workers and Lifecycle. The short version, which is all you need for this comparison:
- The browser checks for a new worker on navigations into scope, on functional events and subresource fetches once the registration is more than 24 hours stale, and whenever you call
registration.update(). Since Chrome 68, the check for the main script bypasses the HTTP cache by default (updateViaCache: 'imports'). - If the bytes differ, the new worker installs. If
installsucceeds, it moves to waiting. - It stays waiting until no client uses the registration anymore, or until something calls
skipWaiting(). When it activates, every page the old worker controlled switches to it and getscontrollerchange, whether or not the worker callsclients.claim(). (clients.claim()only matters for in-scope pages that had no controller at all.)
Step 3 is where the patterns differ. Three things have to agree for a page to work: the HTML and JavaScript already running in the tab, the active worker, and the caches that worker serves from. After a deploy, the running page belongs to version N and everything on the server belongs to N+1. A pattern decides when the worker moves to N+1 and when, or whether, the page follows.
flowchart LR
subgraph Tab["Open tab"]
P["Page code<br/>version N"]
end
subgraph Browser
W["Active worker<br/>N or N+1?"]
C["Caches<br/>N, N+1 or both?"]
end
S["Server<br/>version N+1"]
P -- "fetch, import(), postMessage" --> W
W --> C
W --> S If the worker moves to N+1 while the page stays on N, the page is running with version skew. Lazy-loaded chunks for version N may be missing from N+1's precache and gone from the server. The N+1 worker may reply in a message format the N page doesn't understand, or migrate an IndexedDB schema under it. Every pattern either prevents skew, bounds it, or accepts it and relies on you to make the versions compatible.
The five patterns at a glance¶
| # | Pattern | Who triggers activation | When the page moves to N+1 | Skew window |
|---|---|---|---|---|
| 1 | Default wait | The browser, when the last client is gone | Next visit after all tabs close, or after a browser restart | None |
| 2 | skipWaiting() immediately | The new worker, in install | Only when the user reloads or navigates | From activation until the next full load, which can be hours |
| 3 | Prompt to reload | The user, by accepting a prompt | Right after they accept | From activation to the reload: about a second |
| 4 | Activate on next navigation | The app, at the next route change | At that route change, with a full load | From activation to the load: about a second |
| 5 | skipWaiting() plus auto-reload | The new worker; the page reloads itself | Right after activation, without asking | About a second, but the reload interrupts the user |
The criteria that matter when you choose:
- Time to adoption: how long until most users run N+1. It matters for bug fixes and security fixes.
- Skew exposure: how long, and in how many tabs, old code runs against the new worker.
- Interruption: whether the user sees a reload they didn't ask for.
- Data-loss risk: whether unsaved input can be lost.
- Multi-tab behavior: what happens in the tabs the user isn't looking at.
- Implementation cost: code, tests and UI.
Patterns 1, 2, 3 and 5 in brief¶
Updating Service Workers has the full, commented implementation of every pattern. What matters for the comparison:
Pattern 1: the default wait. You write no update code. The worker installs, waits, and activates when every window in scope has closed, or when the browser restarts and promotes it. It's safe, since a page always runs against the worker that served it and activate can delete old caches freely. It's also slow: a reload doesn't activate a waiting worker (why), mobile operating systems suspend installed PWAs rather than closing them, and installed desktop apps are windows users leave open. Use it when the worker doesn't precache versioned application code (network-first content sites, push-only or offline-page workers), and add periodic update checks.
Pattern 2: skipWaiting() immediately. The new worker calls skipWaiting() in install and activates as soon as it has installed. Open pages that the old worker controlled switch to the new worker when it activates, even without clients.claim(), but their code stays at version N until the next full load. This is the pattern with the most skew: if N's lazy chunks are gone from both the new precache and the server, lazy routes fail. You can make it safe by keeping the previous precache and falling back across caches, and by keeping message formats and schemas backward compatible. Use it when the worker has no versioned precache, or you have done that compatibility work. See Lifecycle and Pitfalls & Anti-Patterns for the failure mode.
Pattern 3: prompt the user to reload. The new worker waits. The page detects it (including a worker that was already waiting before the page loaded), shows a non-blocking prompt, and on accept posts SKIP_WAITING; the worker's message handler calls self.skipWaiting(), and the accepting tab reloads once on controllerchange, behind a refreshing flag. The page side is where the work is; the full vanilla and workbox-window implementation is Pattern 3 on the Updating page, and the hybrid policy later in this post contains a compact version. Three things the comparison adds: a toast users ignore behaves like Pattern 1, so re-show it and combine it with Pattern 4; when one tab accepts, the other tabs now run version-N code against the N+1 worker, so show them a "this tab is out of date" banner instead of reloading them; and it's the only pattern that lets the user pick the moment. Use it when you precache an app shell with hashed assets and users hold state you can't restore automatically.
Pattern 5: skipWaiting() plus automatic reload. Pattern 2 in the worker, plus a guarded location.reload() on controllerchange in every tab. Adoption is as fast as it gets, but a reload in the middle of typing loses input, and a deploy that produces different worker bytes on every request reloads the page in a loop unless a guard stops it (see Guarding against controllerchange reload loops and Pattern 5 on the Updating page). Use it when nobody types into the page (kiosks, dashboards, signage), and as the emergency path for a critical fix, which is what the hybrid policy below uses it for.
Pattern 4 with the Navigation API¶
Users already expect a page change when they click a link. If a worker is waiting, turn the next in-app navigation into a full page load after activating the new worker: no prompt, no unexpected reload, and a skew window as short as Pattern 3's. The Updating page shows the router-hook version; the Navigation API makes it router-independent, because every navigation the page can intercept passes through one navigate event. The API is in Chrome and Edge 102, Firefox 147 and Safari 26.2 according to MDN's compatibility data. Where it's missing, the function below does nothing, and you fall back to Pattern 3's prompt.
/**
* When a service worker is waiting, turns the next in-app navigation into a full
* page load served by the new worker. Must be installed BEFORE your router adds
* its own navigate listener, so stopImmediatePropagation() can keep the router
* from intercepting the navigation.
*/
export function installUpdateOnNavigate(registration, { timeoutMs = 3000, onActivate } = {}) {
if (!('navigation' in window) || !navigator.serviceWorker) return () => {};
const container = navigator.serviceWorker;
let fullLoadPending = false;
async function onNavigate(event) {
if (fullLoadPending) {
// Our own location.assign(): keep the router from intercepting it,
// so the browser performs a real cross-document navigation.
event.stopImmediatePropagation(); // (1)!
return;
}
const waiting = registration.waiting;
if (!waiting || !container.controller) return;
if (!event.canIntercept || !event.cancelable || event.hashChange) return;
if (event.downloadRequest !== null || event.formData || event.navigationType === 'traverse') return; // (2)!
event.preventDefault();
event.stopImmediatePropagation();
fullLoadPending = true;
onActivate?.(); // lets other controllerchange listeners know a full load is coming
const switched = new Promise((resolve) =>
container.addEventListener('controllerchange', resolve, { once: true }));
waiting.postMessage({ type: 'SKIP_WAITING' });
// Activation waits for the old worker's in-flight events; don't hang the click.
await Promise.race([switched, new Promise((r) => setTimeout(r, timeoutMs))]);
location.assign(event.destination.url);
}
navigation.addEventListener('navigate', onNavigate);
return () => navigation.removeEventListener('navigate', onNavigate);
}
location.assign()fires a newnavigateevent synchronously. Without this branch, the router intercepts it and the "full load" becomes another same-document navigation.- Downloads, form submissions and back/forward traversals are left alone. Canceling a traversal is only allowed in limited cases, and replaying a POST body with
location.assign()would turn it into a GET.
Things to know before you ship it:
- Registration order matters. Listeners run in the order they were added. Install the hook before your router's
navigatelistener. Routers that still use the History API and click handlers don't go throughnavigatefor their ownpushState()calls, only for the link click itself. Test your router. - Programmatic navigations reject. If your code calls
navigation.navigate()and the hook cancels it, both thecommittedandfinishedpromises reject with anAbortError. Catch them. - The full load costs what a first navigation costs. State that lives only in memory is lost unless you persist it. That's usually acceptable at a route change.
Five deploy scenarios¶
The same deploy, published at 10:00, looks very different depending on the user and the pattern. This table assumes the worker precaches a hashed app shell with lazy-loaded route chunks, and that the page checks for updates when it becomes visible.
| Scenario | 1. Wait | 2. skipWaiting() | 3. Prompt | 4. Next navigation | 5. Auto-reload |
|---|---|---|---|---|---|
| One tab open since yesterday, user active | Stays on N until all tabs close | Moves to N+1 worker at once; lazy routes can 404 unless old caches are kept | Sees a prompt; on N+1 a second after accepting | On N+1 at the next click, with a full load | Page reloads at about 10:00 |
| Three tabs open | All stay on N | All three get skew | Accepting tab reloads; the other two show a stale banner | Each tab moves at its own next navigation | All three reload |
| Installed mobile app, suspended overnight | Stays on N; resume isn't a navigation | Moves to N+1 worker on the first check after resume | Prompt after the first check after resume | First navigation after resume | Reloads right after resume |
| Offline at 10:00, back at 12:00 | Update installs at the first check after 12:00, then waits | Activates when the check succeeds | Prompt after 12:00 | After 12:00, at the next navigation | Reload after 12:00 |
| Filling in a long form | Unaffected | Unaffected until a lazy chunk or API call hits the skew | Should delay the prompt while the form is dirty | Unaffected; next route change is a full load | Loses the input unless saved first |
Two things stand out. Pattern 1's weakness is concentrated in installed apps, which are exactly the users you want on the latest version. Pattern 5's weakness is concentrated on your most engaged users, the ones in the middle of a task. Patterns 3 and 4 are the only ones with no bad row, which is why they are the usual default for app shells.
Decision table¶
| If your app… | Use | Why |
|---|---|---|
| Has no versioned precache (network-first HTML and assets, push-only worker) | 2 | Nothing for old pages to lose; fastest adoption |
| Is a content site with a small offline fallback | 1 or 2 | Skew is harmless; keep it simple |
| Is an SPA with hashed, lazily loaded chunks | 4, with 3 as fallback | Short skew window, no interruption |
| Holds unsaved user state (editors, long forms, uploads) | 3 | The user picks the moment; save state before reloading |
| Runs unattended (kiosk, dashboard, signage) | 5 | No user to ask; guard the reload |
| Can't keep message formats or schemas backward compatible | 3 or 4, never 2 | Keep the skew window to about a second |
| Must be able to push a security fix within minutes | Hybrid, with 5 for critical releases | The release, not the codebase, decides the urgency |
| Is distributed through an app store wrapper or TWA | Same as the web version | The worker updates the same way inside a Trusted Web Activity |
Letting each release choose its pattern¶
Most apps need different behavior for different releases. A copy change can wait for the next navigation. A fix for data corruption can't. A release that changes the page-to-worker message protocol must not coexist with old pages at all. Build that into the release instead of the code: stamp each worker with its release metadata, and let the page read it from the waiting worker before deciding what to do.
flowchart TD
W["New worker installed and waiting"] --> Q["Page asks it: GET_RELEASE"]
Q --> C{"urgency critical, or<br/>page build below minClientBuild?"}
C -- yes --> S["Save state, SKIP_WAITING,<br/>reload now (pattern 5)"]
C -- no --> U{"urgency silent?"}
U -- yes --> N["Apply at next navigation (pattern 4)"]
U -- no --> P["Prompt (pattern 3),<br/>and apply at next navigation (pattern 4)"] The build stamps two values: a build number in the HTML (<html data-build="42">) and a RELEASE object in the worker. Use plain increasing integers, so comparisons never need a semver parser.
// Stamped by the build. build: monotonically increasing integer.
// urgency: "silent" | "normal" | "critical"
// minClientBuild: oldest page build that can safely run against this worker.
const RELEASE = { build: 42, urgency: "normal", minClientBuild: 0 };
self.addEventListener("message", (event) => {
switch (event.data?.type) {
case "GET_RELEASE":
// Reply on the transferred port so the page can await the answer.
event.ports[0]?.postMessage(RELEASE);
break;
case "SKIP_WAITING":
self.skipWaiting();
break;
}
});
A waiting worker can receive messages. Posting to registration.waiting starts it if it isn't running, and it can answer on a MessageChannel port. Messaging & the Clients API covers the details.
import { installUpdateOnNavigate } from './update-on-navigate.js';
// Build number stamped into the HTML (<html data-build="42">) at build time.
const PAGE_BUILD = Number(document.documentElement.dataset.build ?? 0);
const RELOAD_KEY = 'sw-policy-reloaded-at';
/** Sends a message to a worker and waits for a reply on a MessageChannel. */
function askWorker(worker, message, timeoutMs = 3000) {
return new Promise((resolve, reject) => {
const { port1, port2 } = new MessageChannel();
const timer = setTimeout(() => {
port1.close();
reject(new Error('worker did not answer'));
}, timeoutMs);
port1.onmessage = (event) => {
clearTimeout(timer);
port1.close();
resolve(event.data);
};
worker.postMessage(message, [port2]);
});
}
/** Calls back once per worker that reaches "installed" while a controller exists. */
function whenWaiting(registration, callback) {
const seen = new WeakSet();
const notify = (worker) => {
if (worker && !seen.has(worker)) {
seen.add(worker);
callback(worker);
}
};
// An update may already be waiting from an earlier visit.
if (registration.waiting && navigator.serviceWorker.controller) notify(registration.waiting);
registration.addEventListener('updatefound', () => {
const installing = registration.installing;
installing?.addEventListener('statechange', () => {
// No controller means first install, which is not an update.
if (installing.state === 'installed' && navigator.serviceWorker.controller) notify(installing);
});
});
}
function reloadedRecently(windowMs = 10_000) {
try {
const at = Number(sessionStorage.getItem(RELOAD_KEY));
return Number.isFinite(at) && Date.now() - at < windowMs;
} catch {
return false;
}
}
/**
* @param {ServiceWorkerRegistration} registration
* @param {{
* prompt(api: { release: object, accept(): void, dismiss(): void }): void,
* saveState?(): Promise<void>,
* onStaleTab?(): void,
* }} ui
*/
export function initUpdatePolicy(registration, ui) {
const container = navigator.serviceWorker;
// Track the previous controller: going from none to one is clients.claim()
// on first install, not an update, even if it happens long after page load.
let lastController = container.controller;
let acceptedHere = false;
let reloading = false;
// Silent and normal releases also apply at the next in-app navigation,
// which performs its own full load: suppress the stale-tab UI for it.
installUpdateOnNavigate(registration, { onActivate: () => { reloading = true; } });
container.addEventListener('controllerchange', () => {
const previous = lastController;
lastController = container.controller;
if (!previous || reloading) return;
if (!acceptedHere) {
ui.onStaleTab?.(); // another tab activated the update: don't reload under the user
return;
}
if (reloadedRecently()) return; // loop breaker
reloading = true;
try {
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
} catch {
/* ignore */
}
location.reload();
});
function activate(worker) {
acceptedHere = true;
if (registration.waiting !== worker) {
// Already activated by another tab (or replaced by a newer build).
location.reload();
return;
}
worker.postMessage({ type: 'SKIP_WAITING' });
}
whenWaiting(registration, async (worker) => {
let release = { build: 0, urgency: 'normal', minClientBuild: 0 };
try {
release = await askWorker(worker, { type: 'GET_RELEASE' });
} catch {
// Older workers don't answer: treat the update as a normal one.
}
const pageTooOld = PAGE_BUILD < release.minClientBuild;
if (release.urgency === 'critical' || pageTooOld) {
// This page must not keep running against the new worker's caches or APIs.
await ui.saveState?.();
activate(worker);
} else if (release.urgency === 'silent') {
// Nothing to show: installUpdateOnNavigate() applies it on the next route change.
} else {
ui.prompt({ release, accept: () => activate(worker), dismiss() {} });
}
});
}
Wire it up once, after registration:
import { initUpdatePolicy } from "./update-policy.js";
import { showUpdateToast, showStaleBanner } from "./update-ui.js";
import { saveDraftsToIndexedDB } from "./drafts.js";
import { startRouter } from "./router.js";
if ("serviceWorker" in navigator) {
const registration = await navigator.serviceWorker.register("/sw.js");
// Install before the router starts, so the navigate hook runs first.
initUpdatePolicy(registration, {
prompt: showUpdateToast,
saveState: saveDraftsToIndexedDB,
onStaleTab: showStaleBanner,
});
startRouter();
}
The loop breaker has a cost: its 10-second window also blocks legitimate back-to-back reloads, so two critical releases deployed within ten seconds of each other apply the second one at the next navigation instead. That's the right trade-off for a loop breaker, but know that it's there.
Two operating rules make the policy trustworthy:
- Set
minClientBuildwhenever the page-to-worker contract changes. New message types, renamed cache names the page reads, and IndexedDB schema changes the worker applies all qualify. Updating Service Workers describes how to version the protocol. - Reserve
criticalfor fixes that justify interrupting users. If every release is critical, you have built Pattern 5 with extra steps.
How the patterns map to Workbox and the Vite PWA plugin¶
| Tool setting | Pattern | Notes |
|---|---|---|
Workbox generateSW with skipWaiting: false (default) | 1, or 3 with workbox-window | The generated worker includes the SKIP_WAITING message listener |
Workbox generateSW with skipWaiting: true, clientsClaim: true | 2 | Add the cache fallback yourself if you lazy-load chunks |
workbox-window: waiting event plus messageSkipWaiting() | 3 | controlling fires on every controllerchange; reload only in the tab that accepted |
vite-plugin-pwa registerType: "prompt" (default) | 3 | onNeedRefresh() shows your UI; updateServiceWorker() sends SKIP_WAITING |
vite-plugin-pwa registerType: "autoUpdate" | 2 plus reload, close to 5 | Forces skipWaiting and clientsClaim only when injectRegister is auto or null; onNeedReload (1.3.0 and later) lets you defer the reload |
Changing an installed base from an automatic pattern to a prompt is harder than the reverse. The old worker keeps calling skipWaiting() until it has been replaced, and old pages have no prompt UI. Start with the conservative pattern. Workbox Fundamentals and Vite PWA Plugin cover the configuration in detail.
Measuring adoption¶
You can't pick a pattern well without knowing how long your users actually stay on old versions. Report both the page build and the controlling worker's build with your analytics, and chart the share of sessions on the latest build over the days after each deploy.
// Check navigator.standalone first: iOS web apps whose manifest says "standalone"
// match display-mode: fullscreen (WebKit bug 264218), and macOS Safari Dock apps
// also set it. Firefox taskbar apps match minimal-ui, so check all app-like modes.
// Chromium also matches fullscreen for a browser tab in F11 mode, a small overcount.
function installedDisplayMode() {
if (navigator.standalone === true) return "standalone";
for (const mode of ["window-controls-overlay", "standalone", "fullscreen", "minimal-ui"]) {
if (matchMedia(`(display-mode: ${mode})`).matches) return mode;
}
return null;
}
// Reports which page build and which worker build served this page view.
// A mismatch means the page is running with version skew.
export async function reportVersions(sendEvent) {
const pageBuild = Number(document.documentElement.dataset.build ?? 0);
const controller = navigator.serviceWorker?.controller;
let workerBuild = null;
if (controller) {
workerBuild = await new Promise((resolve) => {
const { port1, port2 } = new MessageChannel();
const timer = setTimeout(() => resolve(null), 2000); // old workers may not answer
port1.onmessage = (event) => {
clearTimeout(timer);
resolve(event.data?.build ?? null);
};
controller.postMessage({ type: "GET_RELEASE" }, [port2]);
});
}
sendEvent("app_version", {
page_build: pageBuild,
worker_build: workerBuild,
skew: workerBuild !== null && workerBuild !== pageBuild,
display_mode: installedDisplayMode() ?? "browser",
});
}
Split the adoption curve by display mode. If installed users lag far behind browser users, your pattern relies on tab closes that installed apps rarely do, and you need Pattern 3 or 4 plus update checks on visibilitychange. A nonzero skew rate on a pattern that shouldn't have any points to a worker calling skipWaiting() that you didn't expect. Analytics for PWAs shows how to send these events reliably, including from installed apps that are often offline.
Browser support for the building blocks¶
Patterns 1, 2, 3 and 5 only use service worker APIs that every engine has shipped since 2018. Pattern 4 and the hybrid policy's silent path depend on the Navigation API, which reached all three engines only in January 2026.
| API used | Used by | Chrome / Edge | Firefox | Safari (macOS / iOS) |
|---|---|---|---|---|
skipWaiting() | 2, 3, 4, 5 | ✅ 41 | ✅ 44 | ✅ 11.1 / 11.3 |
clients.claim() | 2, 5 | ✅ 42 | ✅ 44 | ✅ 11.1 / 11.3 |
registration.update() | Periodic checks in all patterns | ✅ 45 | ✅ 44 | ✅ 11.1 / 11.3 |
registration.waiting, updatefound, controllerchange | 3, 4, 5, hybrid | ✅ 40 | ✅ 44 | ✅ 11.1 / 11.3 |
postMessage() to a waiting worker with a MessageChannel port | 3, 4, hybrid | ✅ 40 | ✅ 44 | ✅ 11.1 / 11.3 |
updateViaCache (default "imports") | Update checks | ✅ 68 | ✅ 57 | ✅ 11.1 / 11.3 |
Navigation API navigate event | 4, hybrid | ✅ 102 | ✅ 147 | ✅ 26.2 |
NavigateEvent.canIntercept | 4, hybrid | ✅ 105 | ✅ 147 | ✅ 26.2 |
Support data as of September 2026. Check MDN's Navigation API compatibility data and caniuse for live data.
Two practical consequences. First, Chrome 102 to 104 expose navigation but not canIntercept; installUpdateOnNavigate() reads !event.canIntercept as "can't intercept" and does nothing there, which is the safe outcome. Second, Firefox ESR releases before 147 and Safari before 26.2 still have a meaningful share in some enterprise and older-iOS audiences. On those, the hybrid policy's silent releases wait like Pattern 1 until the next full load, so keep a periodic registration.update() and consider treating silent as normal when "navigation" in window is false.
Common pitfalls¶
- Reloading on the first
controllerchange. On a first visit,clients.claim()firescontrollerchangewithout any update. Track the previous controller and ignore the change from none to one. - Reloading every tab. Only the tab that accepted should reload. Others should show a stale banner.
skipWaiting()plus aggressive cache cleanup. Deleting the previous precache inactivateis what turns skew into 404s. Keep one previous version, or don't skip waiting.- Counting on reloads. A reload doesn't activate a waiting worker. Test updates by closing every tab, or use DevTools' Update on reload only when you understand it forces a new version on every navigation.
- Forgetting installed apps. They rarely close and resuming isn't a navigation. Call
registration.update()onvisibilitychangeand apply updates at navigations. - Putting a changing value in
sw.js. A build timestamp or nonce rendered into every response makes every check an update, which combined with Pattern 5 is a reload loop. - No emergency exit. Whatever pattern you pick, keep a kill-switch worker ready. Pitfalls & Anti-Patterns and Updating Service Workers explain how.
Further reading¶
On this site
- Updating Service Workers: the update algorithm, byte checks and every pattern in depth
- Lifecycle
- Messaging & the Clients API
- Precaching & Runtime Caching
- SPA vs MPA PWAs
- Vite PWA Plugin
- Auditing PWAs After Lighthouse Dropped the PWA Category: testing updates in CI
External references
- Service Workers specification, W3C editor's draft
- ServiceWorkerGlobalScope: skipWaiting() method, MDN
- Navigation API, MDN
- Fresher service workers, by default, Chrome for Developers
- workbox-window, Chrome for Developers
- Handling service worker updates, Chrome for Developers