The idea: a proxy you install in the browser

A service worker is a JavaScript file that the browser runs on its own thread, between a site's pages and the network. Once it controls a page, every request the page makes (the page itself, scripts, images, fetch() calls) first fires a fetch event in the worker. The worker can answer from Cache Storage, a set of named caches it fills itself, go to the network, or both. That is how a progressive web app opens with no network at all: offline first. On the canvas, one tab of example.com, its worker and Cache Storage live in the browser; the origin is across the network.

Inside the browser, the page's requests go to the service worker as fetch events. The worker answers from Cache Storage or forwards the request across the network to the origin. Without a worker, requests go straight to the network.
The worker sees every request from the pages it controls and chooses: cache, network, or both.

Register, install, activate

The first visit always comes from the network: there is no worker yet. The page's script calls navigator.serviceWorker.register('/sw.js'). The browser downloads sw.js and runs its install event, where the worker precaches the app shell with caches.open('cache-v1').addAll(['/index.html', '/app.js']). If any file fails, the whole install fails. Then activate runs (the place to delete old caches) and clients.claim() lets the worker take over the tab that is already open. On the next load the page and its script come from the cache, and with the network off the site still opens (Demo: first visit installs, second visit comes from the cache, Demo: offline first). With no worker, an offline visit just shows the browser's error page (Demo: offline on the very first visit).

A worker controls the pages in its scope, by default the folder sw.js is served from. It needs HTTPS (or localhost), has no access to the DOM, and the browser stops it when it is idle and starts it again for the next event, so it must not keep state in global variables.

Fetch strategies

The fetch handler decides, request by request, where the answer comes from. Here the shell files are always cache first, and the dropdown picks the strategy for the API file /data.json:

StrategyOnlineOfflineGood for
cache firstcache if it has it, else network and storecache, or an error if never fetchedversioned scripts, fonts, images
network firstnetwork, store a copythe last copy from the cachedata that must be current
stale-while-revalidatecache now, refresh the cache in the backgroundcache now; the refresh fails quietlyavatars, feeds, data that can be one visit old

Cache first never asks again: after a deploy the page keeps getting the old /data.json (Demo: cache first: fast, but old after a deploy). Network first gets the new version at once and falls back to the cache when offline (Demo: network first). Stale-while-revalidate is one visit behind: the fetch right after a deploy still shows v1, but it has put v2 in the cache for the next one (Demo: stale-while-revalidate).

Three columns. Cache first: the worker looks in the cache and answers; it goes to the network only on a miss. Network first: the worker asks the network and stores a copy; it reads the cache only when the network fails. Stale-while-revalidate: the worker answers from the cache and at the same time fetches a new copy into the cache for next time.
Three ways to answer the same fetch event: trade freshness for speed and offline support.

Updates and the waiting worker

On every navigation the browser fetches sw.js again and compares the bytes. A changed file is a new worker: it installs next to the old one, into its own cache-v2, and then waits. While the old worker controls any tab, the new one does not run, so one page never mixes two versions. A reload is not enough: the new page is created before the old one is gone, so the old worker always has a client. Only when every tab is closed does v2 activate, delete cache-v1 and serve the new shell (Demo: an update waits until every tab is closed). Sites that want the update at once call self.skipWaiting() in install and usually show a "new version, reload" banner.

Time runs left to right. sw.js v1 is installed and active and controls the tab. After a deploy, v2 is installed and waits while v1 still controls the tab through any number of reloads. When all tabs close, v2 activates, deletes cache-v1 and controls the next page.
A new worker installs at once but takes over only when the old one has no page left.

See also HTTP caching (the browser's own cache, which a service worker sits in front of), cache strategies (the same trade-offs for an application cache in front of a database), the browser event loop and how a browser renders a page.

What the page leaves out

One tab, one worker per version and no timing. It leaves out the HTTP cache under the worker, skipWaiting() as a control, navigation preload, background sync and push messages, the Cache Storage quota and eviction, opaque cross-origin responses, the 24-hour update check for long-running pages, offline fallback pages, and libraries such as Workbox that write these strategies for you.

On the mind map

This page on the Servers & Event Loops mind map · 26 pages in 5 branches · open it to see the whole map and test yourself in recall mode.