W3docs

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, a push, a sync) 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 localhost during development), because a script that can rewrite every response is a serious attack surface.

Why use Service Workers?

BenefitWhat it gives you
Offline supportCache the app shell and critical assets so the app loads with no network connection.
PerformanceRepeat visits are served from a local cache, eliminating round trips and cutting load times.
Background syncDefer failed requests (e.g. a posted comment) and retry them automatically when connectivity returns.
Push notificationsReceive and display messages from a server even when no tab is open.
Full request controlDecide 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.

  1. Register — the page calls navigator.serviceWorker.register(). The browser downloads the script.
  2. Install — the install event fires once per worker version. This is where you pre-cache the files the app needs to run offline.
  3. 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().
  4. Activate — the activate event fires. This is where you clean up caches from previous versions.
  5. Control / Fetch — once active, the worker intercepts fetch events for pages within its scope.
register → install → (waiting) → activate → fetch / push / sync ...

Two methods steer this flow:

  • self.skipWaiting() (in install) tells the new worker to activate immediately instead of waiting.
  • self.clients.claim() (in activate) 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 localhost only. Mixed/insecure pages cannot register a worker.
  • Scope limits interception. A worker at /app/sw.js controls /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 a fetch event — return a Response (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 with clients.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.

StrategyHow it worksBest for
Cache firstReturn the cached copy; only hit the network on a miss.Static assets that rarely change (CSS, fonts, the app shell).
Network firstTry the network; fall back to cache if it fails.Frequently updated content (API responses, news feeds).
Stale-while-revalidateServe 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 Response body can only be read once, which is why you must clone() 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:

  1. Bump CACHE_VERSION (e.g. 'v1''v2') whenever cached assets change.
  2. The new install writes assets into the new cache.
  3. The new activate deletes 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 online and offline events on the window object 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.onmessage listener 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:

Practice

Practice
What are the key characteristics and functionalities of JavaScript Service Workers?
What are the key characteristics and functionalities of JavaScript Service Workers?
Was this page helpful?