Frontend Caching: Service Workers and Cache API

Frontend caching is the difference between a sluggish web app that breaks offline and a fast, resilient experience that works anywhere. Traditional browser caching relies on HTTP headers and gives...

Key Insights

  • Service Workers intercept network requests at the browser level, enabling sophisticated caching strategies that work offline—unlike localStorage which only stores data
  • The Cache API provides versioned, request/response pair storage that survives page reloads and can store complete HTTP responses including headers and binary data
  • Choose cache-first for static assets, network-first for dynamic content, and stale-while-revalidate for the best balance of performance and freshness

Introduction to Frontend Caching

Frontend caching is the difference between a sluggish web app that breaks offline and a fast, resilient experience that works anywhere. Traditional browser caching relies on HTTP headers and gives you minimal control. Service Workers change this completely.

A Service Worker is a JavaScript file that runs separately from your main thread, acting as a programmable proxy between your web app and the network. It intercepts every network request, letting you decide whether to serve cached content, fetch from the network, or implement hybrid strategies.

The Cache API works hand-in-hand with Service Workers to store request/response pairs. Unlike localStorage (limited to 5-10MB of string data), the Cache API can store complete HTTP responses including headers, handle large binary files, and manages cache versioning properly. While localStorage is synchronous and blocks your main thread, the Cache API is promise-based and non-blocking.

Service Worker Lifecycle and Registration

Service Workers follow a specific lifecycle: registration, installation, activation, and then they handle fetch events. Understanding this lifecycle is critical for avoiding common pitfalls.

Here’s how to register a Service Worker:

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

The scope parameter is crucial. A Service Worker at /sw.js controls all pages under the root path. If you register /assets/sw.js, it only controls /assets/* pages. Always place your Service Worker at the root unless you have a specific reason not to.

Here’s a minimal Service Worker file:

// sw.js
const CACHE_VERSION = 'v1';

self.addEventListener('install', (event) => {
  console.log('SW installing...');
  // Force the waiting service worker to become active
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  console.log('SW activating...');
  // Take control of all pages immediately
  event.waitUntil(clients.claim());
});

self.addEventListener('fetch', (event) => {
  console.log('Fetching:', event.request.url);
  // We'll implement caching strategies here
});

The skipWaiting() and clients.claim() calls ensure your new Service Worker takes control immediately instead of waiting for all tabs to close. Use this during development, but be cautious in production—it can cause issues if users have multiple tabs open.

Cache API Fundamentals

The Cache API provides methods to store and retrieve responses. During the install event, cache your critical static assets:

// sw.js
const CACHE_NAME = 'static-v1';
const STATIC_ASSETS = [
  '/',
  '/styles/main.css',
  '/scripts/app.js',
  '/images/logo.png',
  '/offline.html'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        console.log('Caching static assets');
        return cache.addAll(STATIC_ASSETS);
      })
  );
  self.skipWaiting();
});

The event.waitUntil() tells the browser not to terminate the Service Worker until the promise resolves. If any file in addAll() fails to cache, the entire operation fails—this is intentional to ensure consistency.

For dynamic caching, use cache.put():

// Cache a response manually
cache.put(request, response);

// Or use cache.add() for a simple GET request
cache.add('/api/data');

Retrieving from cache is straightforward:

// Get a specific cached response
const response = await caches.match(request);

// Search all caches
const response = await caches.match(request, { ignoreSearch: true });

Cache cleanup during activation prevents old caches from consuming storage:

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames
          .filter(name => name !== CACHE_NAME)
          .map(name => caches.delete(name))
      );
    })
  );
  return self.clients.claim();
});

Caching Strategies

Different content types need different strategies. Static assets benefit from aggressive caching, while API data needs freshness.

Cache-First Strategy - Perfect for static assets that rarely change:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(cachedResponse => {
        if (cachedResponse) {
          return cachedResponse;
        }
        return fetch(event.request);
      })
  );
});

Network-First Strategy - Ideal for API calls where you want the freshest data:

async function networkFirst(request) {
  try {
    const networkResponse = await fetch(request);
    const cache = await caches.open('dynamic-v1');
    cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    const cachedResponse = await caches.match(request);
    return cachedResponse || caches.match('/offline.html');
  }
}

Stale-While-Revalidate - The best of both worlds for semi-dynamic content:

async function staleWhileRevalidate(request) {
  const cache = await caches.open('dynamic-v1');
  const cachedResponse = await cache.match(request);
  
  const fetchPromise = fetch(request).then(networkResponse => {
    cache.put(request, networkResponse.clone());
    return networkResponse;
  });
  
  return cachedResponse || fetchPromise;
}

This returns cached content immediately while updating the cache in the background. Users get instant responses and fresh data on the next request.

Practical Implementation

Here’s a complete Service Worker that combines strategies based on request type:

// sw.js
const STATIC_CACHE = 'static-v2';
const DYNAMIC_CACHE = 'dynamic-v1';
const STATIC_ASSETS = [
  '/',
  '/styles/main.css',
  '/scripts/app.js',
  '/offline.html'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then(cache => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(keys => {
      return Promise.all(
        keys
          .filter(key => key !== STATIC_CACHE && key !== DYNAMIC_CACHE)
          .map(key => caches.delete(key))
      );
    })
  );
  return self.clients.claim();
});

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // Cache-first for static assets
  if (request.destination === 'style' || 
      request.destination === 'script' ||
      request.destination === 'image') {
    event.respondWith(cacheFirst(request));
    return;
  }
  
  // Network-first for API calls
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }
  
  // Stale-while-revalidate for HTML pages
  event.respondWith(staleWhileRevalidate(request));
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  return cached || fetch(request);
}

async function networkFirst(request) {
  const cache = await caches.open(DYNAMIC_CACHE);
  try {
    const response = await fetch(request);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    return await caches.match(request);
  }
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(DYNAMIC_CACHE);
  const cached = await cache.match(request);
  
  const fetchPromise = fetch(request).then(response => {
    cache.put(request, response.clone());
    return response;
  }).catch(() => caches.match('/offline.html'));
  
  return cached || fetchPromise;
}

To manage cache size, implement a maximum entries limit:

async function limitCacheSize(cacheName, maxItems) {
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();
  if (keys.length > maxItems) {
    await cache.delete(keys[0]);
    await limitCacheSize(cacheName, maxItems);
  }
}

// Call after adding to cache
limitCacheSize(DYNAMIC_CACHE, 50);

Debugging and Testing

Chrome DevTools Application tab is your primary debugging tool. It shows registered Service Workers, cache contents, and lets you unregister workers or clear caches.

Add logging to track what’s happening:

self.addEventListener('fetch', (event) => {
  console.log('[SW] Fetch:', event.request.url);
  // Your caching logic
});

Inspect cache contents programmatically:

// Run in DevTools console
caches.open('static-v1').then(cache => {
  cache.keys().then(keys => {
    console.log('Cached URLs:', keys.map(k => k.url));
  });
});

Common pitfalls: Service Workers won’t register without HTTPS (localhost is exempt). Scope issues cause Service Workers to not intercept requests—verify your registration path. Cache invalidation requires updating your version constant and letting the activate event clean up old caches.

To test updates, increment your cache version, reload the page, then close all tabs and reopen to trigger activation. Or use skipWaiting() and clients.claim() for immediate updates during development.

Best Practices and Conclusion

Use Service Workers when you need offline functionality, fine-grained caching control, or background sync. For simple data persistence, localStorage or IndexedDB might suffice.

Always serve Service Workers over HTTPS in production. The security implications of intercepting network requests are significant—HTTPS prevents man-in-the-middle attacks.

Version your caches explicitly. Don’t rely on browser heuristics. Clean up old caches during activation to prevent unbounded storage growth.

Monitor cache hit rates and sizes in production. Use Performance APIs to measure the impact:

performance.mark('cache-check-start');
const cached = await caches.match(request);
performance.mark('cache-check-end');
performance.measure('cache-check', 'cache-check-start', 'cache-check-end');

Service Workers and the Cache API give you control that traditional browser caching never could. Use cache-first for static assets, network-first for critical API calls, and stale-while-revalidate for the best user experience. Implement proper versioning, test thoroughly, and your users will thank you with faster load times and offline capability.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.