jsguides

Browser Service Workers: Offline Caching and Network Interception

Service workers are a powerful browser API that sits between your web application and the network, acting as a programmable network proxy. They enable features like offline support, background sync, push notifications, and sophisticated caching strategies. A service worker runs on its own thread, separate from the main page, and can intercept every network request your application makes.

By the end of this guide, you will know how to register a service worker, cache assets for offline use, implement multiple caching strategies, and handle background sync — all while keeping your cache surface small and predictable.

How service workers fit into the web platform

A service worker is a JavaScript file that runs separately from the main browser thread. It cannot access the DOM directly but can intercept network requests, manage caches, and communicate with open pages through the postMessage API.

The service worker lifecycle has three phases:

  1. Registration — You tell the browser about your service worker file
  2. Installation — The browser downloads and installs the worker
  3. Activation — The worker takes control of the page

Understanding this lifecycle is essential for debugging issues and implementing features like cache updates.

Registering a service worker

Before using a service worker, you must register it in your main JavaScript:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('Service Worker registered:', registration.scope);
    } catch (error) {
      console.error('Service Worker registration failed:', error);
    }
  });
}

The registration returns a ServiceWorkerRegistration object whose scope property indicates which pages the worker controls. The scope defaults to the directory containing the SW file and any subdirectories.

A few things to note:

  • The service worker file must be served from the same origin
  • You cannot register a service worker from a different domain
  • The file must be served over HTTPS (or localhost for development)

The service worker lifecycle

Installation

When the browser finds a new service worker (or the file changes), it downloads the script and fires the install event:

// sw.js
const CACHE_NAME = 'my-cache-v1';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/images/logo.png'
];

self.addEventListener('install', (event) => {
  console.log('Service Worker installing');

  // Pre-cache critical assets
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Caching app assets');
      return cache.addAll(ASSETS_TO_CACHE);
    })
  );

  // Skip waiting to activate immediately
  self.skipWaiting();
});

The event.waitUntil() method extends the installation until the promise resolves. If the promise rejects, the installation fails and the service worker is discarded. Pre-caching critical assets during installation ensures they are available offline before the worker takes control.

Calling self.skipWaiting() tells the browser to activate the worker immediately, even if there is an existing controller. This is useful during development but requires care in production — an immediate activation can surprise users who have the old version of your app open.

Activation

After installation completes, the activate event fires. This is the ideal time to clean up caches from previous versions so stale assets do not linger:

self.addEventListener('activate', (event) => {
  console.log('Service Worker activating');

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          // Delete caches that don't match the current version
          if (cacheName !== CACHE_NAME) {
            console.log('Deleting old cache:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );

  // Take control of all pages immediately
  self.clients.claim();
});

The self.clients.claim() method lets the service worker take control of existing pages immediately, rather than waiting for the next navigation. Without this call, pages that were already open before activation continue using the old worker until the user refreshes.

Intercepting network requests

The core of service worker functionality is the fetch event, which fires for every network request the controlled page makes. Intercepting these requests lets you decide whether to serve from cache, go to the network, or combine both strategies:

self.addEventListener('fetch', (event) => {
  // Handle requests here
  console.log('Intercepted request:', event.request.url);
});

From here, you can implement various caching strategies. Each strategy makes a different trade-off between freshness, speed, and offline reliability, so pick the one that matches your content’s update frequency.

Caching strategies

Cache first (cache fallback to network)

This strategy checks the cache first, then falls back to the network. It prioritizes load speed and works well for static assets that rarely change — stylesheets, fonts, and app shell files are good candidates:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      // Return cached response if found
      if (response) {
        return response;
      }

      // Otherwise fetch from network
      return fetch(event.request).then((networkResponse) => {
        // Optionally cache the new response
        return networkResponse;
      });
    })
  );
});

The main downside is that users may see stale content if you do not also have a strategy for updating the cache. Pair cache-first with a versioned cache name to force re-caching when your app updates.

Network first (network fallback to cache)

For dynamic content that changes frequently — API responses, user data, or live feeds — network-first is a better fit. The user gets the latest data when online, with a cached fallback for offline moments:

self.addEventListener('fetch', (event) => {
  // Only handle GET requests
  if (event.request.method !== 'GET') return;

  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Clone and cache successful responses
        const responseClone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => {
        // If network fails, try the cache
        return caches.match(event.request);
      })
  );
});

Cloning the response before caching is necessary because a response body can only be read once. The clone goes into the cache while the original is returned to the page.

Stale-while-revalidate

This strategy serves cached content immediately while updating the cache in the background. It gives users an instant response and keeps content reasonably fresh. It works well for content that does not need to be perfectly up to date on every request, like article text or product descriptions:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cachedResponse) => {
        // Fetch and update cache in background
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });

        // Return cached response immediately, or wait for network
        return cachedResponse || fetchPromise;
      });
    })
  );
});

The trade-off is that the user may see a slightly outdated version on the first visit, but subsequent visits will show the updated content from the previous background refresh.

Cache versioning

When you update your app, you need to change the cache name to force the service worker to download new assets. A versioned cache name is the simplest approach:

// Increment this number when you update your app
const CACHE_VERSION = 2;
const CACHE_NAME = `my-app-v${CACHE_VERSION}`;

Alternatively, use a timestamp or hash to avoid manual version bumps:

const CACHE_NAME = `my-app-${Date.now()}`;

Then clean up old caches in the activation handler as shown earlier. Without this cleanup step, old caches accumulate and waste storage space on the user’s device.

Communicating with the service worker

Pages can send messages to the service worker and receive responses through the postMessage API. This two-way channel lets the page request actions like skipping the waiting phase or broadcasting cache update notifications:

// From the main page
const sw = navigator.serviceWorker.controller;

if (sw) {
  sw.postMessage({ type: 'SKIP_WAITING' });
}

// Listen for messages from the Service Worker
navigator.serviceWorker.addEventListener('message', (event) => {
  console.log('Message from SW:', event.data);
});

The service worker receives these messages in its own message event listener. This pattern is especially useful for telling users when a new version is available and letting them trigger a refresh:

// In the Service Worker
self.addEventListener('message', (event) => {
  if (event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Keeping the message format consistent across your app makes debugging easier — use a type field and a predictable payload shape.

Handling background sync

The Background Sync API lets you defer actions until the user has stable connectivity. This is particularly useful for form submissions, data uploads, or any operation that should not be lost if the network drops:

// In your main page
async function sendData(data) {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('send-data');
    console.log('Sync registered');
  } else {
    // Fallback: send immediately
    await fetch('/api/data', { method: 'POST', body: JSON.stringify(data) });
  }
}

The sync registration is persistent — even if the user closes the browser, the service worker will attempt the sync when connectivity returns. The worker handles the actual data transmission in its sync event handler:

// In the Service Worker
self.addEventListener('sync', (event) => {
  if (event.tag === 'send-data') {
    event.waitUntil(syncData());
  }
});

async function syncData() {
  // Retrieve data from IndexedDB and send to server
}

Storing the pending data in IndexedDB before registering the sync is a common pattern. When the sync fires, the worker reads from IndexedDB, sends the data, and clears the stored entry on success.

When to use service workers

Service workers are ideal for:

  • Progressive Web Apps — Offline functionality and installability
  • Asset caching — Faster page loads and offline support
  • Background processing — Background sync, push notifications
  • Network optimization — Advanced caching strategies

However, service workers add complexity. For simple websites that do not need offline support, the extra overhead may not be worth it. Each cached asset and each intercepted request adds a maintenance burden, so scope the worker to the features your users actually need.

Cache updates without surprises

Changing a cache name is only the first step. You also need a plan for when new assets should take over — whether that happens on the next visit, after a refresh prompt, or immediately after activation. A clear update story prevents users from seeing half-old, half-new pages and reduces support questions about stale files.

Offline mode as a product choice

Offline support is useful, but it should match the product. Some pages only need a cached shell and a fresh network request for data, while others need a full offline experience. Decide which routes, assets, and requests must work without a connection, then cache only that surface area. Smaller caches are easier to reason about and faster to update.

Debugging the lifecycle

When a service worker does not behave as expected, check registration scope, HTTPS, and cache versioning first. Then inspect the install and activate events to make sure the new worker is actually taking control. Once you know which phase failed, the bug usually becomes easier to isolate. The lifecycle is predictable, but only if each phase is visible during testing.

Keep the cache surface small

A service worker works best when it owns a limited set of assets and requests. If you cache every response, updates become harder to reason about and stale content becomes harder to remove. Keep only the routes and files that matter for the offline story, then let the rest go back to the network. Smaller caches are easier to version and easier to inspect.

Treat updates as user experience

A worker update is not just a deployment step — it is a user-facing event. Decide whether the new version should take over immediately or wait until the next navigation, then make that choice consistent. If you prompt the user to refresh, explain why. Clear update behavior builds trust and lowers the chance that a stale tab lingers in the background.

Keep refresh prompts simple

If you do ask the user to reload, keep the message short and clear. A vague notice leaves people wondering whether anything actually changed. A direct prompt explains what they gain by refreshing, which makes service worker updates feel less mysterious and more intentional.

Prefer predictable cache keys

Cache keys are easiest to manage when they are stable and easy to inspect. Versioned names, route-specific entries, and a small cache set make the worker’s behavior easier to read. When you can guess what lives in the cache without opening devtools, maintenance gets much smoother.

See Also