Service workers give you programmatic control over the browser cache. Instead of relying on HTTP cache headers alone, you can intercept every network request and decide: serve from cache, fetch from network, or both. The right strategy depends on the file type — a font file that never changes needs a different approach than an API response that changes every minute.

This guide maps caching strategies to file types with production-ready code. Each strategy is implemented as a reusable function that plugs into a service worker's fetch event handler.

The Four Caching Strategies

Every caching decision comes down to four strategies:

StrategyBehaviorBest For
Cache-FirstCheck cache, fall back to networkFonts, versioned JS/CSS, static images
Network-FirstCheck network, fall back to cacheAPI responses, HTML pages
Stale-While-RevalidateServe from cache, fetch update in backgroundImages, non-critical resources
Network-OnlyAlways fetch from network, never cacheAnalytics, auth, real-time data

There's also Cache-Only (never hit network), which is useful for precached assets that are guaranteed to be in cache, but in practice Cache-First handles this case too.

Strategy Implementations

// Cache-First: fastest, uses cache if available
async function cacheFirst(request, cacheName = 'static-v1') {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  if (cached) return cached;
  
  const response = await fetch(request);
  if (response.ok) {
    cache.put(request, response.clone());
  }
  return response;
}

// Network-First: freshest, falls back to cache offline
async function networkFirst(request, cacheName = 'dynamic-v1', timeout = 3000) {
  const cache = await caches.open(cacheName);
  
  try {
    const response = await Promise.race([
      fetch(request),
      new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout))
    ]);
    if (response.ok) {
      cache.put(request, response.clone());
    }
    return response;
  } catch (err) {
    const cached = await cache.match(request);
    if (cached) return cached;
    throw err;
  }
}

// Stale-While-Revalidate: fast AND fresh
async function staleWhileRevalidate(request, cacheName = 'swr-v1') {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  
  // Fetch update in background (don't await)
  const fetchPromise = fetch(request).then(response => {
    if (response.ok) {
      cache.put(request, response.clone());
    }
    return response;
  });
  
  // Return cached if available, otherwise wait for network
  return cached || fetchPromise;
}

Strategy by File Type

The fetch event handler routes requests to the appropriate strategy based on the request URL and type.

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // Skip non-GET requests
  if (request.method !== 'GET') return;
  
  // Skip cross-origin requests (CDN, analytics, etc.)
  if (url.origin !== self.location.origin) return;
  
  event.respondWith(routeRequest(request, url));
});

async function routeRequest(request, url) {
  const path = url.pathname;
  
  // Fonts: cache-first, long TTL
  if (path.match(/\.(woff2|woff|ttf|otf)$/)) {
    return cacheFirst(request, 'fonts-v1');
  }
  
  // Versioned static assets (hashed filenames): cache-first
  if (path.match(/\.(js|css)$/) && path.match(/\.[a-f0-9]{8}\./)) {
    return cacheFirst(request, 'static-v1');
  }
  
  // Images: stale-while-revalidate
  if (path.match(/\.(jpg|jpeg|png|webp|avif|gif|svg|ico)$/)) {
    return staleWhileRevalidate(request, 'images-v1');
  }
  
  // API responses: network-first with 3s timeout
  if (path.startsWith('/api/')) {
    return networkFirst(request, 'api-v1', 3000);
  }
  
  // HTML pages: network-first
  if (request.headers.get('accept')?.includes('text/html')) {
    return networkFirst(request, 'pages-v1', 5000);
  }
  
  // Everything else: stale-while-revalidate
  return staleWhileRevalidate(request, 'misc-v1');
}

Fonts: Cache-First (Indefinitely)

Font files never change after deployment. A WOFF2 file is immutable content — the same URL always returns the same bytes. Cache-first is perfect: serve instantly from cache, only fetch on the first load.

Keep fonts in a separate cache (fonts-v1) so they survive cache cleanup of other categories. Users download your fonts once and never again, even across deploys.

Pair service worker caching with <link rel="preload" as="font" type="font/woff2" crossorigin> in the HTML to ensure fonts are fetched and cached on the first page load.

Images: Stale-While-Revalidate

Images display instantly from cache (perceived performance) while the service worker checks for updates in the background. If the image has changed (e.g., user updated their avatar), the next page load gets the fresh version.

This is the best strategy for images because: (1) images are large, so network latency hurts perceived performance; (2) slightly stale images are acceptable in most cases; (3) the background revalidation keeps the cache fresh without blocking rendering.

Cache limit consideration: Images are the largest cache consumers. Limit the image cache to a fixed number of entries (e.g., 200) and evict the oldest when full. See the storage management section below.

API Responses: Network-First with Timeout

API data needs to be fresh. Network-first ensures the user gets current data when online. The cached fallback provides offline functionality — the user sees stale data with a "you're offline" indicator rather than a broken page.

The 3-second timeout is important: if the network is slow (not offline, just bad), the user gets cached data quickly instead of staring at a spinner. Adjust the timeout based on your UX requirements.

Precaching: Install-Time Asset Loading

Precaching downloads and caches specific assets during the service worker's install event — before the user requests them. This guarantees critical assets are available offline from the first page load.

const PRECACHE_ASSETS = [
  '/',
  '/css/app.css',
  '/js/app.js',
  '/fonts/inter-regular.woff2',
  '/fonts/inter-bold.woff2',
  '/images/logo.svg',
  '/offline.html',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('precache-v1').then(cache => {
      return cache.addAll(PRECACHE_ASSETS);
    })
  );
  self.skipWaiting(); // Activate immediately
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    // Clean up old cache versions
    caches.keys().then(keys => {
      return Promise.all(
        keys
          .filter(key => key.startsWith('precache-') && key !== 'precache-v1')
          .map(key => caches.delete(key))
      );
    })
  );
  self.clients.claim(); // Take control of all pages
});

What to precache: Only critical assets needed for the core experience — the app shell (HTML, CSS, JS), fonts, and key images. Don't precache everything: a 50MB precache on first visit would destroy the user's experience.

Versioning: Change the cache name (precache-v1 to precache-v2) when precached assets change. The activate event cleans up old cache versions. With build tools like Workbox, this versioning is automatic based on file content hashes.

Cache Storage Limits and Cleanup

Browsers impose storage quotas. Chrome allows up to 80% of available disk space (shared with IndexedDB, LocalStorage, etc.). Safari is more restrictive — roughly 1GB, with eviction after 7 days of no visits.

// Check available storage
async function checkStorage() {
  if (navigator.storage && navigator.storage.estimate) {
    const estimate = await navigator.storage.estimate();
    console.log(`Used: ${(estimate.usage / 1024 / 1024).toFixed(1)}MB`);
    console.log(`Available: ${(estimate.quota / 1024 / 1024).toFixed(1)}MB`);
    console.log(`Percentage: ${((estimate.usage / estimate.quota) * 100).toFixed(1)}%`);
  }
}

// Limit cache entries (LRU-style)
async function limitCacheSize(cacheName, maxEntries) {
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();
  if (keys.length > maxEntries) {
    // Delete oldest entries (first in, first out)
    const toDelete = keys.slice(0, keys.length - maxEntries);
    await Promise.all(toDelete.map(key => cache.delete(key)));
  }
}

// Call after each cache.put()
async function staleWhileRevalidateWithLimit(request, cacheName, maxEntries = 200) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  
  const fetchPromise = fetch(request).then(async response => {
    if (response.ok) {
      cache.put(request, response.clone());
      await limitCacheSize(cacheName, maxEntries);
    }
    return response;
  });
  
  return cached || fetchPromise;
}

Recommended limits by cache type:

  • Fonts: No limit (small number of files, cache indefinitely)
  • Static assets (JS/CSS): 50 entries (versioned files, old versions naturally replaced)
  • Images: 200 entries (images are large, limit aggressively)
  • API responses: 100 entries (data changes frequently, don't hoard stale responses)
  • HTML pages: 30 entries (relatively small, but keep limited for freshness)

Offline Fallback by Content Type

When both cache and network fail, serve appropriate offline fallbacks by content type:

// Enhanced network-first with offline fallbacks
async function networkFirstWithFallback(request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open('pages-v1');
      cache.put(request, response.clone());
    }
    return response;
  } catch (err) {
    // Try cache
    const cached = await caches.match(request);
    if (cached) return cached;
    
    // Determine fallback based on content type
    const accept = request.headers.get('accept') || '';
    
    if (accept.includes('text/html')) {
      // HTML: show offline page
      return caches.match('/offline.html');
    }
    
    if (accept.includes('image/')) {
      // Images: show placeholder
      return caches.match('/images/offline-placeholder.svg');
    }
    
    if (accept.includes('application/json')) {
      // API: return offline JSON
      return new Response(
        JSON.stringify({ error: 'offline', message: 'You appear to be offline' }),
        { headers: { 'Content-Type': 'application/json' } }
      );
    }
    
    // Everything else: generic error
    return new Response('Offline', { status: 503 });
  }
}

Using Workbox for Production

The code above is educational. For production, use Google's Workbox library, which provides battle-tested implementations of all these strategies plus precaching with content hashing, background sync, and more.

import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';

// Precache build assets (auto-generated manifest)
precacheAndRoute(self.__WB_MANIFEST);

// Fonts: cache-first, never expire
registerRoute(
  ({ request }) => request.destination === 'font',
  new CacheFirst({
    cacheName: 'fonts',
    plugins: [
      new ExpirationPlugin({ maxEntries: 30 }),
    ],
  })
);

// Images: stale-while-revalidate, max 200 entries, 30-day expiry
registerRoute(
  ({ request }) => request.destination === 'image',
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 200,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

// API: network-first, max 100 entries, 1-day expiry
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-responses',
    networkTimeoutSeconds: 3,
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 24 * 60 * 60, // 1 day
      }),
    ],
  })
);

Workbox handles cache versioning, storage limits, background sync, and precache manifests automatically. It's the standard tool for production service workers.

Service worker caching turns your web app into a reliable experience regardless of network conditions. The key insight is that different file types have different freshness requirements: fonts never change, images change rarely, API data changes constantly. Matching the caching strategy to the freshness requirement gives you the best combination of speed and correctness.

Start with the route-based strategy map (fonts → cache-first, images → stale-while-revalidate, API → network-first). Add precaching for the app shell. Add storage limits to prevent cache bloat. Then move to Workbox when you need production-grade reliability.