Service Workers and Offline Caching

· 7 min read · Updated March 23, 2026 · advanced
javascript service-worker pwa caching offline web-api

Service Workers let you intercept network requests and control what gets cached. With the right setup, your web app works even when the user has no internet connection at all.

This guide walks you through the complete setup: registering a Service Worker, caching assets during installation, intercepting fetch requests, and choosing the right caching strategy for different types of content.

What You Need to Know First

A Service Worker is a JavaScript file that runs separately from your main page. It cannot access the DOM, but it can intercept network requests and read from and write to the Cache API. Think of it as a programmable proxy sitting between your app and the network.

Service Workers only work over HTTPS and on localhost. If you are testing locally, most browsers give you a free pass. In production, you need a valid TLS certificate.

Step 1: Register a Service Worker

Before the browser will do anything with a Service Worker, you must register it. You do this in your main JavaScript file, usually on page load:

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

The scope determines which pages the Service Worker controls. By default, it covers the directory where sw.js lives and all subdirectories. You can narrow it by passing a scope option:

navigator.serviceWorker.register('/sw.js', { scope: '/app/' });

Registration is asynchronous. The browser queues the registration and resolves the promise once it has successfully downloaded the Service Worker file.

Step 2: Handle the Installation Event

The browser fires an install event when it first downloads your Service Worker. This is the right place to pre-cache static assets so they are available offline immediately:

// sw.js
const CACHE_NAME = 'app-cache-v1';
const ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/images/icon.svg'
];

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

  // Keep the worker in the installing phase until this promise resolves
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Caching app shell assets');
      return cache.addAll(ASSETS);
    })
  );

  // Activate immediately without waiting for existing pages to close
  self.skipWaiting();
});

event.waitUntil() is critical here. If the promise you pass to it rejects, the installation fails and the browser discards the Service Worker. The skipWaiting() call tells the browser to activate as soon as installation finishes, rather than waiting until all pages under its scope close.

Step 3: Handle the Activation Event

The activate event fires after installation completes. Use it to clean up old caches left over from previous versions of your Service Worker:

const CURRENT_CACHE = 'app-cache-v1';

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

  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CURRENT_CACHE) {
            console.log('Deleting old cache:', cacheName);
            return caches.delete(cacheName);
          }
        })
      );
    })
  );

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

self.clients.claim() lets the activated Service Worker start controlling pages that are already open. Without it, the worker only takes control over pages opened after activation.

Step 4: Intercept Fetch Requests

The real work happens in the fetch event. Every time your page requests a resource, the Service Worker can intercept it and decide what to return:

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

  event.respondWith(
    // Your caching strategy goes here
  );
});

You must call event.respondWith() synchronously or the browser uses its default network behavior. Pass a promise that resolves to a Response object.

Step 5: Choose a Caching Strategy

Different resources call for different strategies. Here are the three most common ones.

Strategy A: Cache First, Fall Back to Network

Good for: static assets like images, CSS, and JavaScript files that do not change often.

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

  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse; // Found in cache, return it
      }

      // Not in cache, go to network
      return fetch(event.request).then((networkResponse) => {
        // Optionally cache the response for next time
        const responseClone = networkResponse.clone();
        caches.open(CURRENT_CACHE).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return networkResponse;
      });
    })
  );
});

The browser checks the cache first. If it finds a match, it returns it immediately without hitting the network. If not, it fetches from the network and caches a copy for future requests.

Strategy B: Network First, Fall Back to Cache

Good for: content that changes frequently, like API responses or user-generated content.

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

  event.respondWith(
    fetch(event.request)
      .then((networkResponse) => {
        // Cache a copy for offline use
        const responseClone = networkResponse.clone();
        caches.open(CURRENT_CACHE).then((cache) => {
          cache.put(event.request, responseClone);
        });
        return networkResponse;
      })
      .catch(() => {
        // Network failed, try the cache
        return caches.match(event.request);
      })
  );
});

This tries the network first and gives the user fresh content when available. Only when the network request fails does it fall back to the cache.

Strategy C: Stale While Revalidate

Good for: balancing speed with freshness. The user gets an instant response while the cache updates in the background.

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

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

        // Return cached response if available, otherwise wait for network
        return cachedResponse || fetchPromise;
      });
    })
  );
});

The user sees a cached response immediately if one exists. In the background, the Service Worker fetches fresh content and updates the cache. On the next request, the user gets the updated version.

Step 6: Update the Cache When Your App Changes

When you deploy a new version of your app, you need to update the cache. The browser detects changes to the Service Worker file by comparing the new file byte-for-byte with the one it has stored. If they differ, the browser treats it as a new version.

The standard approach is to change the cache name whenever you deploy:

const CACHE_NAME = 'app-cache-v2'; // Increment this on each deployment

Then clean up old caches in the activation handler as shown in Step 3. Users who already have your app cached get the old version until they reload the page. To prompt them automatically, send a message from the page:

// In your main app JS
navigator.serviceWorker.ready.then((registration) => {
  // Check if there is a new Service Worker waiting
  if (registration.waiting) {
    registration.waiting.postMessage({ type: 'SKIP_WAITING' });
  }
});
// In sw.js
self.addEventListener('message', (event) => {
  if (event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Step 7: Test Your Offline Setup

The easiest way to test Service Workers is in Chrome DevTools:

  1. Open DevTools and go to the Application tab.
  2. Select Service Workers on the left. You should see your registered worker listed.
  3. Go to the Cache Storage section. You can see all named caches and verify that your assets are cached.
  4. To test offline behavior, check the Offline box in the Network tab, then reload the page.

If the page loads with the network disabled, your caching is working.

You can also simulate an update by editing your sw.js file. After saving, the browser detects the change and fires a new install event. Reload the page to pick up the new version.

Common Pitfalls

The Service Worker file must be served over HTTPS. If you register a Service Worker on http://, the browser ignores it silently. Use a tool like ngrok for local HTTPS during development.

Cache everything before activation. If your install handler fails to cache all assets, the entire Service Worker is discarded and your users get no offline support. Consider caching a minimal subset during install and handling the rest lazily.

Watch out for fetch redirects. If a fetch request redirects, the response is still cacheable, but you need to handle the redirect chain correctly. Caching a 301 or 302 response is usually not what you want.

Cache size limits. Browsers impose storage limits per origin. Large caches can hit these limits and cause eviction. Cache only what you need.

See Also