Service Workers
Learn JavaScript Service Workers: their lifecycle, registration, caching strategies, offline support, and how to ship clean updates with cache versioning.
Service Workers: Building Powerful Offline-First Web Applications
A Service Worker is a script your browser runs in the background, separate from your web page, with no direct access to the DOM. It sits between your web application and the network as a programmable proxy: every request the page makes can be intercepted, inspected, served from a cache, or rewritten before it ever reaches the server.
That single capability unlocks the features users now expect from modern web apps: working offline, near-instant repeat loads, background data sync, and push notifications. Service Workers are the engine behind Progressive Web Apps (PWAs).
This chapter explains what Service Workers are, the lifecycle they go through, how to register one, the common caching strategies, and how to ship updates without serving stale files. The Service Worker API is built entirely on Promises, so a working grasp of async/await and the Fetch API will help.
What is a Service Worker?
A Service Worker is a kind of web worker: a JavaScript file that runs on its own thread, independent of the page that registered it. Because it runs off the main thread, it cannot block your UI, but it also cannot touch the DOM — it communicates with pages through events and messages.
Key traits that set it apart from a regular page script:
- It is event-driven. The browser starts it when there is work to do (an incoming
fetch, apush, async) and may terminate it when idle. Never assume global state survives between events. - It has a lifecycle. A Service Worker is installed, activated, and only then controls pages. Updates follow strict rules so users are never served a half-updated app.
- It is scoped. A worker can only intercept requests under its scope — by default the directory the script lives in.
- It requires a secure context. Service Workers only run over HTTPS (or
localhostduring development), because a script that can rewrite every response is a serious attack surface.
Why use Service Workers?
| Benefit | What it gives you |
|---|---|
| Offline support | Cache the app shell and critical assets so the app loads with no network connection. |
| Performance | Repeat visits are served from a local cache, eliminating round trips and cutting load times. |
| Background sync | Defer failed requests (e.g. a posted comment) and retry them automatically when connectivity returns. |
| Push notifications | Receive and display messages from a server even when no tab is open. |
| Full request control | Decide per-request whether to use the cache, the network, or custom logic. |
The Service Worker lifecycle
A Service Worker moves through a well-defined set of states. Understanding them is the single most important thing for avoiding "why is my old code still running?" bugs.
- Register — the page calls
navigator.serviceWorker.register(). The browser downloads the script. - Install — the
installevent fires once per worker version. This is where you pre-cache the files the app needs to run offline. - Wait — if an older worker still controls open pages, the new worker waits. It will not activate until every controlled page is closed, unless you call
self.skipWaiting(). - Activate — the
activateevent fires. This is where you clean up caches from previous versions. - Control / Fetch — once active, the worker intercepts
fetchevents for pages within its scope.
register → install → (waiting) → activate → fetch / push / sync ...Two methods steer this flow:
self.skipWaiting()(ininstall) tells the new worker to activate immediately instead of waiting.self.clients.claim()(inactivate) lets the active worker take control of pages that are already open, instead of only controlling pages loaded after activation.
Why the wait phase exists: it guarantees that a single version of your code controls a page for its entire lifetime, so you never mix old HTML with newly cached scripts. Use
skipWaiting()deliberately, because it can swap the controlling worker out from under an active user.
Constraints to keep in mind
- HTTPS or
localhostonly. Mixed/insecure pages cannot register a worker. - Scope limits interception. A worker at
/app/sw.jscontrols/app/and below — not the whole origin. Place the script at the site root to control everything. - No DOM access. Update the page by posting messages or by having the page read from caches.
- The worker can be killed at any time. Store anything that must persist in the Cache Storage, IndexedDB, or Web Storage — not in worker globals.
Step 1 — Register the Service Worker
In your page's JavaScript, register the script with navigator.serviceWorker.register(). Always feature-detect first, and register after the page loads so the worker doesn't compete with the first render:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered, scope:', registration.scope);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
});
}The register() call returns a Promise that resolves with a ServiceWorkerRegistration. Its scope tells you which URLs this worker controls.
Step 2 — Write the Service Worker script
Create a separate file (here, sw.js) for the worker itself. Inside it you handle the lifecycle events and decide how requests are served. The example below pre-caches an app shell on install, cleans up old caches on activate, and serves a cache-first strategy with an offline fallback:
const CACHE_VERSION = 'v1';
const PRECACHE_URLS = ['/', '/index.html', '/styles.css', '/offline.html'];
// Install: pre-cache the app shell.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting(); // activate this version immediately
});
// Activate: remove caches from previous versions.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_VERSION)
.map((key) => caches.delete(key))
)
)
.then(() => self.clients.claim()) // take control of open pages
);
});
// Fetch: serve from cache, fall back to network, then to the offline page.
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return (
cached ||
fetch(event.request).catch(() => caches.match('/offline.html'))
);
})
);
});A few things worth highlighting:
event.waitUntil(promise)keeps the worker alive until the Promise settles, so the browser does not terminate it mid-install or mid-activate.event.respondWith(promise)is how you answer afetchevent — return aResponse(from the cache) or a Promise that resolves to one.self.skipWaiting()forces the new version to activate without waiting for old pages to close. Combined withclients.claim(), the new worker takes over right away. Convenient in development; use it carefully in production, because swapping the controlling worker mid-session can interrupt active users.
Step 3 — The worker takes control
After install and activate complete, the worker controls pages within its scope and its fetch handler intercepts their requests. Note that the first load of a page does not go through the worker — the worker is installing during that load. From the second load onward, requests flow through your fetch handler.
Common caching strategies
There is no single "right" caching strategy — you pick one per resource type based on how fresh the data must be.
| Strategy | How it works | Best for |
|---|---|---|
| Cache first | Return the cached copy; only hit the network on a miss. | Static assets that rarely change (CSS, fonts, the app shell). |
| Network first | Try the network; fall back to cache if it fails. | Frequently updated content (API responses, news feeds). |
| Stale-while-revalidate | Serve the cached copy immediately, then fetch a fresh copy in the background for next time. | Resources where speed matters more than perfect freshness (avatars, thumbnails). |
A network-first handler looks like this:
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache a copy for offline use, then return the fresh response.
const copy = response.clone();
caches.open('v1').then((cache) => cache.put(event.request, copy));
return response;
})
.catch(() => caches.match(event.request))
);
});Tip: A
Responsebody can only be read once, which is why you mustclone()it before both caching and returning it.
Updating a Service Worker
When you change sw.js, the browser detects the byte difference, downloads the new file, and runs its install event. The new worker then waits (unless you call skipWaiting()). The cache-versioning pattern above is what keeps updates clean:
- Bump
CACHE_VERSION(e.g.'v1'→'v2') whenever cached assets change. - The new
installwrites assets into the new cache. - The new
activatedeletes every cache whose key isn't the current version, evicting stale files.
This guarantees users never get a mix of old and new assets after you deploy.
Real-World Example: Connectivity Status Notifications
This example demonstrates a feature commonly used in many modern websites and applications, such as streaming services like Netflix or cloud-based apps like Google Docs, to inform users about their connectivity status. By notifying users when they are offline, these platforms enhance user experience by ensuring that users are aware of potential issues with data syncing or streaming interruptions. This example focuses on the main-thread UI integration, while the Service Worker script remains the same as the previous example.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Connectivity Notifier</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 50px;
}
#status {
padding: 10px;
border-radius: 5px;
color: #fff;
font-size: 24px;
}
.online {
background-color: #4caf50;
animation: blinker 1s linear infinite;
}
.offline {
background-color: #f44336;
animation: blinker 1s linear infinite;
}
@keyframes blinker {
50% {
opacity: 0.5;
}
}
</style>
</head>
<body>
<h1>Connectivity Notifier</h1>
<p id="status" class="offline">Checking connectivity...</p>
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("sw.js").then(function () {
console.log("Service Worker Registered");
});
window.addEventListener('online', () => {
const statusElement = document.getElementById("status");
statusElement.textContent = "Online";
statusElement.className = "online";
});
window.addEventListener('offline', () => {
const statusElement = document.getElementById("status");
statusElement.textContent = "Offline";
statusElement.className = "offline";
});
}
</script>
</body>
</html>Explanation:
- Connectivity Check: The main page listens for
onlineandofflineevents on thewindowobject and updates the UI immediately, avoiding the unreliable polling approach. - User Feedback: The page displays current connectivity status, helping users understand how to integrate background capabilities with a responsive interface.
- Code Cleanup: The dead
navigator.serviceWorker.onmessagelistener was removed, as the Service Worker script does not send any messages.
Conclusion
Service Workers turn the browser into a programmable network proxy, making it possible to build apps that are fast, resilient, and usable offline. The keys are understanding the lifecycle (install → wait → activate → fetch), choosing a caching strategy that matches each resource, and using cache versioning so updates roll out cleanly.
To go deeper into the building blocks Service Workers rely on, see:
- Promise and async/await — every Service Worker API is Promise-based.
- Fetch API — the same
fetch()you intercept in the worker. - Storage API and localStorage & sessionStorage — where to persist data the worker manages.
- Event loop: microtasks and macrotasks — how the worker schedules its event-driven work.