What is a Service Worker? How it works and when to use it
A service worker is a JavaScript file the browser runs in the background, separate from the page. It sits between your app and the network and lets you cache assets, intercept requests, work offline, and receive push notifications. Here's how it works, what its lifecycle looks like, and when to reach for one.
On this page
- Where service workers fit
- The basics
- The lifecycle
- A minimal example
- Updating a deployed service worker
- When to use a service worker
- FAQ
- Do service workers have access to the DOM?
- Why does my new service worker not take effect immediately?
- Can I use a service worker on plain HTTP?
- What is the difference between a service worker and a web worker?
- Summary
A service worker is a JavaScript file the browser runs in the background, separate from your web page. It registers against an origin and a path, and once active it acts as a programmable network proxy between the page and the network: every request the page makes can be intercepted, rewritten, served from a cache, or short-circuited. That single capability is what unlocks offline experiences, instant repeat-visit loads, background sync, and push notifications on the open web.
The MDN Web Docs call it "essentially a proxy server that sits between web applications, the browser, and the network (when available)". The proxy framing is the cleanest way to think about it: the worker is not part of the page's JavaScript, does not have access to the DOM, and cannot block the page — it lives in its own thread and only responds to events.
Where service workers fit
Service workers are a kind of web worker, which means they run off the main thread and have no DOM access. Unlike a regular web worker (which is a script you spawn and communicate with), a service worker is registered once, persists across page loads, and runs long after the page that registered it has been closed. The browser starts it on demand whenever an event in its scope fires.
The features a service worker unlocks are the building blocks of the modern app-like web:
- Offline support — serve cached HTML, CSS, JavaScript, and images when the network is unavailable.
- Faster repeat visits — short-circuit cached resources instead of going to the network.
- Background sync — replay a request when connectivity returns (e.g. send a queued message).
- Push notifications — wake the worker from a push event even when no tab is open.
- Request interception — rewrite headers, respond with synthesized data, or simulate network conditions.
This is why a service worker is the technical core of a progressive web app — the offline behavior and installability of a PWA are both implemented on top of one.
The basics
A service worker is a plain JavaScript file that lives on your origin. Three rules govern how you write it:
- It must be served from a secure origin. Browsers only register service workers on HTTPS, with one exception:
http://localhostis treated as secure so local development works without certificates. - It has no DOM access. No
document, nowindow, no direct access to the page. Communication with the page goes throughpostMessageand the Clients API. - It is fully asynchronous. APIs that are synchronous on the main thread, like synchronous
XMLHttpRequestorlocalStorage, are not available. The worker is built around promises; the lifecycle events all take aevent.waitUntil(promise)call to signal completion.
It also matters where the file lives. The browser scopes a service worker to the directory it is served from. A worker at /sw.js controls every page under /, a worker at /blog/sw.js controls only /blog/*, and so on. The convention is to put it at the root so it can act as a catch-all proxy.
The lifecycle
A service worker goes through a deliberately careful lifecycle. According to the web.dev service worker lifecycle guide by Jake Archibald, the intent of the lifecycle is to:
- Make offline-first possible.
- Allow a new service worker to get itself ready without disrupting the current one.
- Ensure an in-scope page is controlled by the same service worker (or no service worker) throughout.
- Ensure there is only one version of your site running at once.
The states a service worker moves through are:
- Parsed — the browser has fetched
sw.jsand is checking whether it is new (byte-different from the currently installed version, or the first one ever). - Installing — the
installevent fires. This is where you typically pre-cache your app shell withcaches.open(name).then(cache => cache.addAll([...])).event.waitUntil(promise)extends the install until the promise settles. - Installed (waiting) — the worker has installed successfully but the old version is still controlling open pages. The new worker waits until all pages using the old version are closed.
- Activating — the
activateevent fires. This is where you typically clean up old caches from previous versions. - Activated — the worker is now in control of the page. From this point on it receives
fetchandpushevents for any in-scope navigation. - Redundant — if a newer version installs and activates, the old worker is replaced and becomes redundant.
Two details from the MDN service worker API page shape how the lifecycle feels in practice:
- A page's fetches do not go through a service worker unless the page request itself went through a service worker. This means the first time you ship a worker, the current page keeps loading directly from the network; the worker only takes effect on the next navigation or after a refresh. If you need the new worker to control the current page immediately, call
clients.claim()from insideactivate. - A new service worker does not interrupt an in-progress page. It waits for the page to close. This is intentional: it prevents two versions of your app from running at the same time, which is the only way to guarantee that IndexedDB, the cache storage, and your other persistent state have a single source of truth.
A minimal example
The shortest end-to-end demonstration is a cache-on-install, network-falling-back-to-cache fetch handler. From MDN's Using Service Workers guide:
// sw.js
const CACHE = 'app-shell-v1';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll([
'/',
'/styles.css',
'/app.js',
]))
);
});
self.addEventListener('activate', (event) => {
// clean up old caches
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});
Registration from a page looks like this:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
A few patterns worth pulling out of the example:
installis for filling the cache. Use it to pre-cache the app shell (HTML, CSS, the JS bundle, key images) so the first offline visit can render something useful.activateis for cleaning up old caches. Bump the cache name on each release (e.g.app-shell-v1→app-shell-v2) and delete the previous one here.fetchis the proxy.event.respondWith(...)tells the browser "I will handle this request myself." Returningfetch(event.request).catch(() => caches.match(event.request))is a common "network-first, fall back to cache" pattern.- A service worker is JavaScript, but with a different scope. You can split the file into modules and
importthem statically; you just cannot use dynamicimport().
Updating a deployed service worker
Updating a service worker is what most teams get wrong first. The lifecycle is designed to make updates safe but unobtrusive — you ship a new sw.js file, the browser downloads it in the background, runs install (with the new caches), and waits for the old page to close before activate.
A common update flow:
- Bump a constant in
sw.js(e.g.CACHE = 'app-shell-v2') and update the files incache.addAll([...]). - The browser detects the byte difference on the next navigation and starts the new worker.
- The new worker's
installpopulates the new cache; the old worker continues serving the open page. - When every tab of the old page is closed, the new worker's
activateruns and deletes the old cache. - The next navigation is controlled by the new worker.
If you need the new worker to take effect without waiting for users to close every tab, you can call self.skipWaiting() from install and clients.claim() from activate. The trade-off is that two versions of your app can briefly run side-by-side; the web.dev lifecycle guide walks through the cases where each approach is appropriate.
When to use a service worker
Service workers are the right tool when you need any of the following:
- Offline support for a published website — the canonical use case. PWAs, documentation sites, news readers, anything users may open on flaky connections.
- Faster repeat loads — serving the app shell and critical assets from the cache instead of the network. Pair with the cache and HTTP caching headers from your origin for the right balance.
- Background sync — the Background Sync API lets a worker replay a request after the device comes back online.
- Push notifications on the web — the Push API wakes the worker on receipt, and the Notifications API shows the actual message.
- A programmable network proxy — rewriting requests, adding headers, or stubbing responses in development.
When it is not the right tool:
- You only need to cache HTTP responses. A plain
Cache-Controlheader on the response is cheaper and works without JavaScript. - You need to do work on every page load. A service worker only handles events; if the work is on the critical rendering path it belongs in the page itself.
- You need access to the DOM. A service worker has none; build a regular web worker or put the code in the page.
FAQ
Do service workers have access to the DOM?
No. A service worker is a kind of web worker, which means it runs in its own thread with no access to document, window, localStorage, or any DOM API. The only way to talk to the page is postMessage from the worker to a controlled client, and message events from the page back to the worker. This is deliberate: it forces the worker to be non-blocking and keeps the page safe from anything the cached worker script might try to do to it.
Why does my new service worker not take effect immediately?
A page that loaded without a service worker does not start being controlled by one until the next navigation. MDN's Service Worker API page calls this out explicitly: "by default, a page's fetches won't go through a service worker unless the page request itself went through a service worker." The first registration only takes effect on the next page load, on the next open of the site, or after a refresh. If you need immediate control, call clients.claim() from inside the worker's activate event.
Can I use a service worker on plain HTTP?
No, except in two narrow cases. Service workers only register on secure contexts — origins served over HTTPS. The exception that matters for development is http://localhost: browsers treat localhost as a secure origin so you can test service workers without setting up a certificate. The other exception is browser-internal flags (Firefox has a developer-tools checkbox to allow service workers over HTTP) for debugging only; do not ship a public site over plain HTTP. Service workers over HTTP would be trivially exploitable by network attackers.
What is the difference between a service worker and a web worker?
A regular web worker is a script you spawn from a page with new Worker('foo.js'); it lives as long as the spawning page is open, communicates with the page over postMessage, and is destroyed when the page closes. A service worker is registered once with navigator.serviceWorker.register(...), persists independently of any page, runs events fired by the browser (fetch, push, sync, message), and can outlive every page that registered it. Both are off-main-thread, both have no DOM access, but a service worker is the durable, event-driven one.
Summary
A service worker is a small, persistent JavaScript file that lives on the user's device, sits between the page and the network, and lets the page work offline, load faster, and receive push events. The lifecycle — install, activate, then continuous fetch and other events — is the most subtle part of the API, and it is the part that catches most teams the first time. Once you understand that a new worker does not interrupt an in-progress page and that pages loaded without a worker do not start using one until the next navigation, the rest of the API falls into place: pre-cache on install, clean up old caches on activate, and respondWith from fetch to be the proxy the browser is waiting for.
Sources
- MDN — Service Worker API (developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
- CC-BY-SA 2.5+
- MDN contributors)
- MDN — Using Service Workers (developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers
- CC-BY-SA 2.5+
- MDN contributors)
- web.dev — The service worker lifecycle (web.dev/articles/service-worker-lifecycle
- CC BY 4.0 content / Apache 2.0 code
- Jake Archibald)
Share this guide
Related guides
- localStorage vs sessionStorage vs cookies: which one to use?
The practical differences between localStorage, sessionStorage and cookies — capacity, lifetime, security and when to pick each one.
- What is a Progressive Web App (PWA)? How it works and when to use it
A Progressive Web App (PWA) is a website built with web platform technologies that feels like a native installed app — it can be installed on a device, work offline, and run in the background. Here's how PWAs work, what they need, and when they make sense.
- What is Server-Side Rendering (SSR)? How it works and when to use it
Server-side rendering generates the full HTML for a page on the server in response to a request — delivering a complete, crawlable page to the browser. Here's how SSR works, how it compares to client-side rendering, and when it makes sense for your project.