SevyDevy

Guide · Frontend

Service Workers Explained: Offline-First Caching Without Stale Apps

21 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • Service workers explained simply: a service worker is a script the browser keeps between your pages and the network. It can answer requests from saved copies when the network fails.
  • Pick a caching strategy per file type. Cache-first (use the saved copy, else download) suits files with a content hash in their name. Network-first (try the server, else the saved copy) suits HTML and API data.
  • Updates wait on purpose. A new service worker installs, then waits until no open tab uses the old one. A normal reload does not end that wait.
  • To avoid stale apps, never serve HTML cache-first, change sw.js (the service worker file) on every deploy, and offer an update button. It calls skipWaiting (a command that lets the new worker take over) and then reloads.
  • Beginners can stop after the section on stale apps. Engineers then get our Chromium 153 test results: when update checks happen, how skipWaiting broke an open tab, a kill switch and Background Sync limits.

What goes wrong when a web app meets a patchy mobile network?

Without help, the page simply fails to load. The browser shows its own offline error, and everything your user had open is gone. Service workers explained in one line: they let your own code answer requests when the network cannot.

Picture a common setup. A small team runs a single-page web app that delivery riders open on cheap Android phones to see the day's drop list. Riders lose signal in lifts, basements and on highways. The setup is made up, but both bugs below are real and we reproduced them.

The first bug is the obvious one. A rider reloads the drop list in a basement and gets a blank error page. The data they saw five minutes ago is not available.

The second bug comes after the team adds a service worker to fix the first. Offline now works, but riders keep running last week's app. A fix ships on Monday and some phones still show the old screen on Friday.

Both bugs have the same root. A service worker gives you full control over what the browser loads, including the power to keep loading old files forever. This guide shows how to fix the first bug without causing the second.

Which words do you need before learning service workers?

You need about eight terms. Each one is explained again the first time it matters, so skim this table and come back to it.

The vocabulary used in this guide
TermWhat it means in plain words
Service workerA JavaScript file the browser runs in the background, separate from your page. It can see and answer the page's network requests.
ScopeThe part of your site a service worker looks after. By default it is the folder that sw.js sits in.
Controlled pageA page whose requests go through a service worker. The page can check this with navigator.serviceWorker.controller.
Cache StorageA store of saved request and response pairs that your code fills and empties. It is separate from the browser's normal HTTP cache.
PrecacheFiles the service worker saves while it installs, before any page needs them.
Runtime cachingSaving responses later, as the page asks for them.
Install, waiting, activateThe stages a new service worker passes through before it controls pages.
Hashed file nameA build output name like app.3f9a1c.js, where the middle part changes whenever the file's content changes.

Service workers explained: what does a service worker actually do?

A service worker is a script that sits between your pages and the network and decides how each request is answered. Service workers explained this way sound like a proxy, and that is close: a small proxy that lives inside the browser.

Your page registers it once with navigator.serviceWorker.register('/sw.js'). After that, every request from a controlled page fires a fetch event, a callback your worker code handles. The worker can answer from Cache Storage, go to the network, or combine both.

It only runs on secure pages. The W3C Service Workers spec requires secure contexts, which in practice means HTTPS, and lets browsers allow localhost for development. That is why a plain http staging server often shows no service worker at all.

Where a service worker sits

Notice that the page never talks to the network directly once it is controlled. The worker picks a source for every request, so a wrong choice here is what makes an app stale.

Diagram connections:

  • Drop list page to Service worker (sw.js) (every request in scope)
  • Service worker (sw.js) to Which kind of request?
  • Which kind of request? to Cache Storage (hashed JS, CSS, fonts)
  • Which kind of request? to Your web server (HTML and API first)
  • Your web server to Cache Storage (save a copy)
  • Drop list page: Asks for HTML, JS, CSS and /api/drops
  • Service worker (sw.js): Gets a fetch event for every request in scope
  • Which kind of request?: The worker's own routing code
  • Cache Storage: Saved copies your code put there
  • Your web server: The live, newest files and data

Here is one picture for the whole idea. Think of a housing society gate guard who keeps a parcel room. Residents are your pages, couriers are the network, and the parcel room is Cache Storage.

When a resident asks for something, the guard decides whether to hand over a parcel already in the room or call the courier. A guard who always hands out the old parcel is exactly the stale-app bug.

Where the picture breaks: a real guard can be replaced instantly. A new service worker cannot. It has to wait until no resident is still dealing with the old guard, which is the next section.

How does the service worker lifecycle work, from install to activate?

A service worker goes through install, then possibly waiting, then activate, and only then controls pages. Each stage exists to stop two versions of your app from mixing.

The service worker lifecycle

The key box is the question in the middle. If any tab still uses the old worker, the new one waits, and a reload does not count as closing the tab.

Diagram connections:

  • Page calls register() to Browser fetches sw.js
  • Browser fetches sw.js to install event (bytes changed)
  • install event to Discarded (redundant) (a precache file failed)
  • install event to Old worker controls a tab? (precache done)
  • Old worker controls a tab? to Waiting (Yes)
  • Old worker controls a tab? to activate event (No)
  • Waiting to activate event (all tabs closed or skipWaiting)
  • activate event to Controls pages
  • Page calls register(): Runs on every page load; cheap if nothing changed
  • Browser fetches sw.js: Compares it byte for byte with the current one
  • install event: Precache files with cache.addAll()
  • Discarded (redundant): Any precache file failed; old worker keeps running
  • Old worker controls a tab?: Checked after install succeeds
  • Waiting: Installed but not in charge yet
  • activate event: Delete old caches here
  • Controls pages: New loads go through the new worker

Install: save the app shell

The install event fires once per new version. This is where you precache the app shell, meaning the HTML, JavaScript and CSS the app needs to start.

If any file in cache.addAll() fails, the whole install fails. In our test, a version that tried to precache a missing image went from installing straight to redundant, and the old worker stayed in charge. That is a safety net, not a bug.

The first visit is not controlled

The page that registers the worker is not controlled by it, even after the worker activates. We checked this in Chromium 153: navigator.serviceWorker.controller was null on the first visit and set after one reload.

MDN gives the reason: a document starts life with or without a service worker and keeps that for its lifetime. Calling clients.claim() in the activate event changes this. With it, our first page became controlled at once and received a controllerchange event, the page's signal that its controlling worker changed.

Waiting: why a new version does not start

When you deploy a new sw.js, the browser installs it in the background and then holds it in the waiting stage. It activates only when no tab is using the old worker, unless the new worker calls skipWaiting().

The old one. In our test, three reloads left version 2 waiting and version 1 in control. The web.dev lifecycle article explains why: during a reload, the old page still counts as a tab of the old worker.

Closing one of two tabs was not enough either. Only after every tab closed did the next visit run version 2. Riders who keep the app open for days can stay on the old worker for days.

Activate: clean up

The activate event is the right place to delete caches from older versions. MDN notes that work passed to event.waitUntil() here finishes before the new worker handles its first fetch.

Which caching strategy should each kind of file use?

Match the strategy to how often the file changes and how bad an old copy is. The Workbox caching strategies page, from Chrome's developer docs, describes five standard choices.

Strategy by file type, for the drop list app
RequestStrategyWhyRisk if you get it wrong
HTML pages (navigations)Network-first, fall back to cacheThe page must point to the newest hashed filesCache-first HTML can pin users to an old app
Hashed JS, CSS, fontsCache-firstA new version gets a new name, so a saved copy under that name stays correctUnhashed files stay old until the next worker version
API data like /api/dropsNetwork-first, fall back to cacheFresh data online, last known data offlinePersonal data stays on the device until deleted
Avatars and small imagesStale-while-revalidateShow the saved copy now, refresh it for next timeOne visit always shows the older copy
Payments, POST requestsNetwork-onlyMust always reach the serverA saved copy could show an old or wrong result

Stale-while-revalidate means: answer from the cache at once, and fetch a fresh copy in the background for next time. Network-only means the worker does not touch the cache at all.

We ran each HTML strategy against one deploy. The service worker file did not change; only index.html did. The results explain most stale-app reports.

Three reloads after a new index.html, then with the server switched off (Chromium 153, 24 October 2026)
HTML strategyReloads 1, 2, 3Server down
Cache-firstold, old, oldOld page loads
Network-firstnew, new, newNewest saved page loads
Stale-while-revalidateold, new, newNewest saved page loads
No service workernew, new, newBrowser error page

Cache-first HTML never showed the new page. It stays old until a changed sw.js installs a new cache. That is a common way users end up on last week's app.

A service worker that follows the table

This is the full worker we tested. It precaches the shell, uses network-first for pages and API data, and cache-first for everything else from your own site. Beginners can skim it; the two tables above carry the idea.

javascript
const VERSION = 'v1';
const CACHE = 'app-' + VERSION;
const PRECACHE = ['/', '/app.3f9a1c.js', '/app.8b2e4d.css', '/offline.html'];

self.addEventListener('install', function (event) {
  event.waitUntil(caches.open(CACHE).then(function (cache) {
    return cache.addAll(PRECACHE);
  }));
});

self.addEventListener('activate', function (event) {
  event.waitUntil(caches.keys().then(function (names) {
    const old = names.filter(function (n) { return n.startsWith('app-') && n !== CACHE; });
    return Promise.all(old.map(function (n) { return caches.delete(n); }));
  }));
});

self.addEventListener('message', function (event) {
  if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
});

self.addEventListener('fetch', function (event) {
  const req = event.request;
  const url = new URL(req.url);
  if (req.method !== 'GET' || url.origin !== self.location.origin) return;
  if (req.mode === 'navigate' || url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(req));
  } else {
    event.respondWith(cacheFirst(req));
  }
});

async function networkFirst(req) {
  const cache = await caches.open(CACHE);
  try {
    const res = await fetch(req);
    if (res.ok) await cache.put(req, res.clone());
    return res;
  } catch (err) {
    const saved = await cache.match(req);
    if (saved) return saved;
    if (req.mode === 'navigate') return cache.match('/offline.html');
    return Response.error();
  }
}

async function cacheFirst(req) {
  const hit = await caches.match(req);
  if (hit) return hit;
  const res = await fetch(req);
  if (res.ok) {
    const cache = await caches.open(CACHE);
    await cache.put(req, res.clone());
  }
  return res;
}

What just happened? The cache name includes the version, so each deploy gets a fresh cache and activate deletes the old ones. Unknown pages fall back to offline.html when the network fails. Cache-first here assumes every other file from your site has a hashed name, so route unhashed images to stale-while-revalidate instead.

We ran this file unchanged in Chromium 153. With the server off, the drop list reloaded, /api/drops returned its saved copy, and an unvisited page showed offline.html. An API call never made before failed, as it should.

Why do users get stuck on last week's app?

Users get stuck when the browser has no reason to replace the old worker, or a reason it cannot act on. Usually two or three of these causes stack up.

  • HTML is cache-first and sw.js did not change. The browser finds no update, so the old HTML keeps loading the old hashed files.
  • sw.js is byte-for-byte identical after a deploy. The spec compares the new file with the old one byte for byte, and an identical file means no update.
  • The new worker is stuck in waiting because a tab never closes. Installed web apps and pinned tabs can stay open for days.
  • Nothing tells the user an update exists. The new version is ready, but the page has no button to switch to it.

The fix is the same in every case. Put a version string in sw.js that your build changes on every deploy. Serve HTML network-first. Then give users a visible update button, which the engineer sections build.

When does the browser check for a new service worker?

The browser checks after every navigation to a page in scope and when you call registration.update(). Events like push and sync also trigger a check, but only if the last one was over 24 hours ago.

The spec calls a registration stale when more than 86400 seconds (24 hours) have passed since its last update check. Navigations trigger a check whether or not the registration is stale.

The HTTP cache usually does not hide sw.js

By default, the update request for sw.js goes to the server instead of trusting the HTTP cache. The register() option updateViaCache defaults to imports, and MDN says the main script is then always updated from the network.

What we saw in Chromium 153 (Playwright 1.63.0, local server, 24 October 2026)
SetupResult
sw.js sent with max-age=86400, four navigations 2.5 seconds apartsw.js fetched from the server four times
registration.update() with an identical sw.jsNo updatefound event, no new worker
One comment line added to sw.jsNew worker installed and waiting
updateViaCache all, max-age=3600, sw.js changedZero fetches over three navigations, no update seen
Only a file loaded with importScripts() changed, served with no-cacheNew worker installed and waiting
Same, but the imported file was served with max-age=3600Zero fetches of it over three navigations, no update seen
Request headers in our server logThe sw.js requests we logged carried Service-Worker: script

So a long max-age on the main sw.js file is mostly harmless with the default setting. Setting updateViaCache to all brings the risk back, so leave the default unless you have a reason.

Imported files are different. importScripts() is how a classic worker loads other script files, and in the default mode those files may come from the HTTP cache. Give them no-cache headers or hashed names.

For apps that stay open for hours, call registration.update() on a timer. The web.dev lifecycle article suggests an interval such as hourly, which the sample page code below uses.

Is skipWaiting safe, and when should you use clients.claim?

skipWaiting is safe only when the old page can keep working with the new worker. Calling it blindly can break tabs that are already open.

skipWaiting() moves a waiting worker straight to activate. In our test with two tabs open, both tabs switched to version 2 and fired controllerchange, even without clients.claim(). The pages themselves still showed version 1.

How skipWaiting broke an open tab

We loaded version 1, whose page lazy-loads (downloads only when needed) a code chunk named chunk-aaa.js. Version 2 renamed it chunk-bbb.js, deleted the old file from the server, and its activate step deleted old caches.

With skipWaiting in version 2, the open version 1 tab then tried its lazy import and failed with Failed to fetch dynamically imported module. Without skipWaiting, the old worker served the chunk from its cache and the tab kept working.

This is the trap with a skipWaiting call inside install. The new worker takes over pages it did not build, then deletes the files those pages still need.

The safer pattern: ask, then reload

  1. Detect the waiting worker

    Listen for updatefound, the event fired when a new worker starts installing. A worker that reaches installed while the page already has a controller is an update.

  2. Show a small banner

    Tell the user a new version is ready, with one button. Do not interrupt them mid-task.

  3. Send SKIP_WAITING

    On click, the page posts a message to the waiting worker. The worker calls self.skipWaiting() only then.

  4. Reload on controllerchange

    When the new worker takes over, the page reloads once. Now the page, its files and the worker all match.

javascript
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async function () {
    const reg = await navigator.serviceWorker.register('/sw.js');
    function offer(worker) {
      showUpdateBanner(function () {
        worker.postMessage({ type: 'SKIP_WAITING' });
      });
    }
    if (reg.waiting && navigator.serviceWorker.controller) offer(reg.waiting);
    reg.addEventListener('updatefound', function () {
      const worker = reg.installing;
      worker.addEventListener('statechange', function () {
        if (worker.state === 'installed' && navigator.serviceWorker.controller) offer(worker);
      });
    });
    setInterval(function () { reg.update(); }, 60 * 60 * 1000);
  });

  let reloading = false;
  navigator.serviceWorker.addEventListener('controllerchange', function () {
    if (reloading) return;
    reloading = true;
    window.location.reload();
  });
}

Your app supplies showUpdateBanner, which shows the button and runs the function it receives on click. In a real build, this code sits inside your hashed app bundle. In our run, the banner appeared, the click reloaded the tab into version 2, and only the version 2 cache remained.

The reloading flag stops a second reload if controllerchange fires again. This page code does not use clients.claim(), so the first visit never triggers a surprise reload.

Workbox follows the same idea. In workbox-build 7.4.1, generateSW defaults skipWaiting to false and adds a listener for a SKIP_WAITING message. It also defaults clientsClaim and cleanupOutdatedCaches to false (as of 24 October 2026).

How do you recover from a broken service worker?

Deploy a new sw.js that removes itself. The browser checks sw.js on navigations and asks the server by default, so a fixed file reaches users even when old HTML is cached.

The kill switch worker calls skipWaiting during install. On activate it deletes every cache, calls self.registration.unregister(), and asks each open tab to navigate to its own URL again.

In our test, a tab stuck on cache-first HTML moved to the fixed page within a few seconds. Afterwards it had no controller, no registration and no caches.

Keep sw.js at a fixed URL for this reason. The web.dev lifecycle article advises against renaming it. Its example shows why: the old worker keeps serving cached HTML that registers the old file name.

Two scope mistakes to avoid

A worker cannot control pages above its own folder. In our test, /static/sw.js asking for scope / failed with a SecurityError about the maximum scope.

The fix is to serve sw.js from the site root, or send a Service-Worker-Allowed: / response header with it. With that header, the same registration succeeded.

Debugging tip: a force refresh (shift and reload) bypasses the service worker. The spec says navigator.serviceWorker.controller is null for a force refresh, so do not judge your worker on one.

Can a service worker send data later, and which browsers support it in 2026?

Yes, in Chromium-based browsers, through the Background Sync API. The page registers a named sync, and the worker gets a sync event once the device is back online.

In our test, the page registered a sync while the browser was offline. Nothing was sent for three seconds. Within three seconds of going online, the worker's request reached the server and the pending tag cleared.

Support is the catch. MDN's compatibility data (version 8.1.7) lists Chrome 49+, Edge 79+ and Samsung Internet 5+, and no support in Firefox or Safari, including Safari on iOS (as of 24 October 2026).

So treat Background Sync as an extra. Keep unsent actions in IndexedDB, the browser's built-in database, and also retry them when the page opens or the browser's online event fires. The SevyDevy guide on chat app frontend design builds that kind of outbox.

How do you check that your service worker is working?

Run four checks in the browser console of your live site. Each has an expected answer.

  • navigator.serviceWorker.controller should be a ServiceWorker object after one reload. Null means the page is not controlled, often because of scope or a force refresh.
  • Get the registration with await navigator.serviceWorker.getRegistration(). After a deploy, a non-null waiting property means an update is ready and waiting.
  • await caches.keys() should list exactly one current cache, such as app-v2. Several versions mean your activate cleanup is not running.
  • Switch the server off, or use your browser's offline mode, and reload. The page and its last API data should still appear.

For a test you can repeat, drive Chromium with Playwright against a local server. Change sw.js between steps and assert on the controller and the cache names, as we did.

If you also want faster first loads with network-first HTML, look at navigation preload. MDN describes it as starting the network request while the worker boots, instead of after.

How do you explain service workers in a system design interview?

Explain the request routing first, then the update story. Interviewers usually probe the second part, because that is where real apps fail.

  1. State the goal

    The app must open and show last known data offline, and users must reach a new version within one session of a deploy.

  2. Draw the proxy

    Pages, the service worker, Cache Storage and the server. Every request in scope passes through the worker.

  3. Assign strategies

    Network-first for HTML and API data, cache-first for hashed static files, network-only for payments and writes.

  4. Explain the lifecycle

    Install precaches, waiting protects open tabs, activate deletes old caches. A reload does not end waiting.

  5. Design the update flow

    Version string in sw.js, update checks on navigation and on a timer, a banner that sends SKIP_WAITING, reload on controllerchange.

  6. Cover failure and privacy

    A kill-switch worker, clearing cached user data on logout, and an IndexedDB outbox with Background Sync where supported.

Questions people ask

Do service workers work on plain HTTP sites?
No. The W3C spec requires service workers and the pages they control to be secure contexts, which in practice means HTTPS. Browsers may treat localhost as secure for development, so local testing works. A staging server on plain http will silently have no service worker.
Does a service worker make the first visit faster?
No. The page that registers the worker is not controlled by it, and the worker also downloads its precache list in the background, which adds requests. The benefit starts from the second visit, when files come from Cache Storage instead of the network.
What is the difference between the HTTP cache and Cache Storage?
The HTTP cache is managed by the browser using headers like Cache-Control. Cache Storage is managed by your code. MDN notes that the Cache API does not honour HTTP caching headers, and its entries do not expire unless deleted. The browser may still clear a site's storage under disk pressure.
Should I use Workbox or write my own service worker?
For most production apps, a maintained library such as Workbox saves you from subtle bugs in precaching and cache cleanup. Writing a small worker yourself, like the one in this guide, is the best way to learn the lifecycle. Either way, check the defaults: in Workbox 7.4.1, skipWaiting and cleanupOutdatedCaches are off by default.
How do I force every user onto the new version right now?
Use a worker that calls skipWaiting and clients.claim, then reload pages on controllerchange. Do this only when the new version is safe for pages already open, because those pages may request files you removed. For a broken worker, deploy a kill switch that clears caches and unregisters itself.
Is it safe to cache API responses that contain user data?
It can be, but treat Cache Storage as data stored on the device. Entries stay until your code deletes them or the browser evicts the site's storage. Delete cached personal data on logout, never cache payment or write requests, and think about shared family phones.
Does Background Sync work on iPhone?
No, as of 24 October 2026. MDN's compatibility data lists no Background Sync support in Safari on iOS or desktop, or in Firefox. Chrome, Edge and Samsung Internet support it. Keep an outbox in IndexedDB and retry it when the app opens or comes online.

Sources and further reading

Practise this with Lala

Practise