Service Workers and Progressive Web Apps
Learn what a Service Worker is and how it differs from a Web Worker, how to register one and manage its update lifecycle, how it intercepts network requests to enable offline support via the Cache API, and what else turns a React app into an installable Progressive Web App.
A Progressive Web App is a regular website that meets a set of technical criteria — served over HTTPS, described by a web app manifest, and backed by a Service Worker — that let browsers treat it more like a native app: installable to a home screen, capable of working offline, and able to receive push notifications. The Service Worker is the linchpin of all of this: a special kind of background script that sits between the app and the network, able to intercept every request the app makes and decide how to answer it.
What a Service Worker is
A Service Worker is a JavaScript file that the browser runs in its own thread, separate from any page, with no access to the DOM — in that respect it resembles a Web Worker, but its purpose and lifecycle are completely different. Where a Web Worker exists only as long as the page that created it and is used for one-off computation, a Service Worker is registered once, persists independently of any open tab, and stays installed across browser restarts until explicitly unregistered or replaced by a new version.
Once registered and activated, a Service Worker sits as a programmable network proxy between the page and the network: every fetch the page makes (for the HTML document itself, scripts, images, API calls) can be intercepted by the worker's fetch event listener, which decides whether to serve a cached response, go to the network, or apply some combination of both. This interception is what makes offline support possible — without a Service Worker, a browser simply has no built-in way to serve a page when there is no network connection.
A Service Worker's scope is determined by where its file is located: a worker registered at /sw.js can control every page under /, while one registered at /app/sw.js can only control pages under /app/. This is a security boundary the browser enforces, and it's why Service Worker scripts are almost always placed at the site root.
Registration, caching, and the update lifecycle
Registering a Service Worker is a single call — navigator.serviceWorker.register('/sw.js') — typically made once, early in the app's startup code, guarded by a check for browser support (not every environment implements the API). Registration itself is asynchronous and doesn't block anything; the worker downloads, installs, and activates on its own schedule in the background.
The Cache API (available inside the Service Worker and, separately, from the page) provides a key-value store for Request/Response pairs, distinct from both the browser's HTTP cache and from localStorage — it's designed specifically for storing whole network responses so a fetch handler can answer requests without touching the network at all. Common patterns include cache-first (check the cache, fall back to network — good for static assets that rarely change), network-first (try the network, fall back to cache — good for content that should be fresh when possible), and stale-while-revalidate (serve the cached copy immediately while fetching a fresh one in the background to update the cache for next time).
Service Workers have a deliberately cautious update lifecycle to avoid breaking a page mid-session: a new version installs in the background (its install event fires) but does not take over immediately — it waits in a 'waiting' state until every open tab controlled by the old worker has closed, at which point it activates (its activate event fires) and becomes the new controller. This can be shortcut with self.skipWaiting() during install and clients.claim() during activate, which together make a new worker take control immediately rather than waiting for tabs to close — useful for apps that want updates to apply right away, at the cost of a page potentially switching versions of cached assets mid-session.
PWA installability and security
Beyond the Service Worker itself, a site becomes installable as a PWA by adding a web app manifest — a JSON file linked from the HTML with <link rel="manifest">, declaring the app's name, icons at various sizes, start URL, display mode (e.g. standalone, to hide browser chrome), and theme color. Browsers use these criteria — HTTPS, a registered Service Worker, and a valid manifest — to decide when to offer an 'Add to Home Screen' or 'Install' prompt.
Because a Service Worker can intercept and rewrite every network response the page makes, it runs with significant power over the app's behavior, which is exactly why browsers require HTTPS (localhost is exempted for development) for registration — an attacker on an unencrypted connection who could inject a malicious Service Worker would gain persistent control over the site's behavior even after the user leaves that network. Scope restrictions, one Service Worker cannot see or interfere with another origin's traffic, and the same-origin policy all apply to Service Workers just as they do to any other script.
In a React app, most teams don't hand-write a Service Worker's caching logic from scratch — tools like Vite's PWA plugin (vite-plugin-pwa, built on Google's Workbox) generate a worker automatically from the built asset list, wiring up sensible default caching strategies and handling the install/update lifecycle, while still leaving room for custom fetch handling where needed.
Minimal Service Worker: cache-first for static assets
The activate handler clears out old cache versions so updating CACHE_NAME's version string cleans up stale entries. event.waitUntil keeps the Service Worker alive until the given promise settles, preventing it from being killed mid-task.
// sw.js — registered at the site root
const CACHE_NAME = "app-shell-v1";
const APP_SHELL = ["/", "/index.html", "/main.js", "/styles.css"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached ?? fetch(event.request))
);
});Code Examples
Registration is guarded by a feature check and deferred to the window load event so it never competes with initial page rendering; the updatefound listener detects a new version and can surface an 'update available' notice to the user instead of silently swapping versions. Expected result: On first visit the app registers and caches its shell; on a later visit with a new deployment, the console logs an update notice instead of the page silently jumping to new code mid-session.
// main.tsx
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker?.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// a new version is ready — prompt the user to refresh
console.log("New content available; refresh to update.");
}
});
});
})
.catch((err) => console.error("Service Worker registration failed:", err));
});
}Service Worker update lifecycle
A new Service Worker version waits for existing tabs to close before taking over, unless the app opts into an immediate takeover.
install
│ (caches app shell)
▼
waiting
│ (old tabs still open)
▼
activate
│ (old tabs closed, or skipWaiting/clients.claim used)
▼
fetch interception beginsImportant Rules
Register a Service Worker only over HTTPS (or localhost during development) — browsers refuse registration otherwise.
Use event.waitUntil() in install and activate handlers to keep the worker alive until async setup (like caching) finishes.
Version your cache name and delete old caches in the activate handler to avoid serving stale assets indefinitely.
Don't assume an updated Service Worker takes over immediately — by default it waits for all old tabs to close, unless skipWaiting/clients.claim are used.
Pair a Service Worker with a valid web app manifest (name, icons, start_url, display) for installability.
Never cache API responses containing sensitive per-user data with a blanket cache-first strategy — prefer network-first or explicit exclusion for anything that must always be fresh or private.
Where this shows up
News and content sites that need to work on flaky mobile connections, e-commerce apps that want a home-screen icon and app-like feel without a native app store submission, and internal tools used in the field (warehouses, delivery routes) with unreliable connectivity all lean on Service Workers for offline resilience and PWA manifests for installability. Push notifications, built on top of the Service Worker's ability to run even when no tab is open, are the other major reason production apps adopt one.
self.addEventListener("install", (event) => { caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)); // no event.waitUntil — install may complete before caching finishes });
self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)) ); });
Without waitUntil, the browser considers install finished as soon as the handler returns, potentially before the cache population promise resolves, leaving the app shell only partially cached.
self.addEventListener("activate", (event) => { // never deletes old cache versions });
self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) ) ); });
Never cleaning up old cache versions means every deployment adds another full copy of the app shell to storage indefinitely, wasting the user's disk space.
What is the main functional difference between a Web Worker and a Service Worker? A. Web Workers can access the DOM and Service Workers cannot B. A Service Worker persists independently of any open tab and can intercept network requests; a Web Worker exists only for the page that created it and does no network interception C. Service Workers run on the main thread while Web Workers run in the background D. There is no functional difference, only a naming difference
B. A Service Worker persists independently of any open tab and can intercept network requests; a Web Worker exists only for the page that created it and does no network interception — A Service Worker acts as a persistent network proxy that outlives any single tab, while a Web Worker is scoped to the page that spawned it and is used for background computation, not request interception.
[true-false] By default, a newly installed Service Worker takes over control of already-open tabs immediately. A. True B. False
B. False — By default a new Service Worker enters a 'waiting' state until all tabs controlled by the previous worker have closed; skipWaiting() and clients.claim() are required to force an immediate takeover.
Why does Service Worker registration require HTTPS in production? A. HTTPS makes the Cache API run faster B. A Service Worker can intercept and rewrite all of a page's network traffic, so an unencrypted connection would let an attacker inject one and gain persistent control over the site C. It's purely a legacy restriction with no current security rationale D. HTTP does not support the fetch event at all
B. A Service Worker can intercept and rewrite all of a page's network traffic, so an unencrypted connection would let an attacker inject one and gain persistent control over the site — Because a Service Worker has broad power over the app's network behavior and persists across sessions, browsers require HTTPS to prevent a network attacker from injecting a malicious one.
Extend the cache-first fetch handler to fall back to a dedicated offline.html page when both the cache and the network fail for a navigation request (a request whose mode is 'navigate').
self.addEventListener("fetch", (event) => { if (event.request.mode === "navigate") { event.respondWith( fetch(event.request).catch(() => caches.match("/offline.html")) ); return; } event.respondWith( caches.match(event.request).then((cached) => cached ?? fetch(event.request)) ); });
Check event.request.mode === 'navigate' inside the fetch handler and add a .catch() to the fetch() call that returns caches.match('/offline.html') instead of letting the request fail.