Front-end Engineering Lab
PatternsMobile & PWA

Service Worker Strategies

Advanced service worker caching strategies for PWAs

Service Workers are the backbone of Progressive Web Apps. This guide covers advanced caching strategies used by production PWAs like Twitter, Instagram, and Pinterest.

Service Worker Lifecycle

Install → Activate → Fetch/Message → Update → Terminate

Basic Service Worker

// sw.js
self.addEventListener('install', (event) => {
  console.log('Service Worker installing');
  // Force activation
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  console.log('Service Worker activating');
  // Claim all clients
  event.waitUntil(clients.claim());
});

self.addEventListener('fetch', (event) => {
  console.log('Fetching:', event.request.url);
});

Registration

// app/register-sw.ts
export async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) {
    console.log('Service Workers not supported');
    return;
  }

  try {
    const registration = await navigator.serviceWorker.register('/sw.js', {
      scope: '/',
    });

    console.log('Service Worker registered:', registration);

    // Listen for updates
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      
      newWorker?.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // New version available
          console.log('New version available!');
          showUpdateNotification();
        }
      });
    });
  } catch (error) {
    console.error('Service Worker registration failed:', error);
  }
}

// Register on load
if (typeof window !== 'undefined') {
  window.addEventListener('load', registerServiceWorker);
}

Caching Strategies

1. Cache First (Offline First)

Best for: Static assets that rarely change

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

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

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      // Return cached version or fetch from network
      return cachedResponse || fetch(event.request);
    })
  );
});

2. Network First (Fresh Content)

Best for: Dynamic content, API calls

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((networkResponse) => {
        // Clone and cache the response
        const responseClone = networkResponse.clone();
        
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, responseClone);
        });
        
        return networkResponse;
      })
      .catch(() => {
        // Network failed, try cache
        return caches.match(event.request);
      })
  );
});

3. Stale While Revalidate

Best for: Assets that can be slightly outdated

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      // Return cached version immediately
      const fetchPromise = fetch(event.request).then((networkResponse) => {
        // Update cache in background
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, networkResponse.clone());
        });
        
        return networkResponse;
      });
      
      // Return cached version or wait for network
      return cachedResponse || fetchPromise;
    })
  );
});

4. Network Only

Best for: Always need fresh data, no caching

self.addEventListener('fetch', (event) => {
  // Always fetch from network, never cache
  event.respondWith(fetch(event.request));
});

5. Cache Only

Best for: Pre-cached assets, no network needed

self.addEventListener('fetch', (event) => {
  // Only return from cache, never network
  event.respondWith(caches.match(event.request));
});

Advanced Patterns

Route-Based Strategies

// Different strategies for different routes
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
  
  // API calls: Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
  }
  // Images: Cache First
  else if (request.destination === 'image') {
    event.respondWith(cacheFirst(request));
  }
  // HTML: Stale While Revalidate
  else if (request.destination === 'document') {
    event.respondWith(staleWhileRevalidate(request));
  }
  // Everything else: Network First
  else {
    event.respondWith(networkFirst(request));
  }
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  
  const network = await fetch(request);
  const cache = await caches.open(CACHE_NAME);
  cache.put(request, network.clone());
  
  return network;
}

async function networkFirst(request) {
  try {
    const network = await fetch(request);
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, network.clone());
    return network;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;
    
    // Fallback to offline page
    return caches.match('/offline.html');
  }
}

async function staleWhileRevalidate(request) {
  const cached = await caches.match(request);
  
  const fetchPromise = fetch(request).then((network) => {
    caches.open(CACHE_NAME).then((cache) => {
      cache.put(request, network.clone());
    });
    return network;
  });
  
  return cached || fetchPromise;
}

Cache Expiration

const CACHE_NAME = 'dynamic-v1';
const MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const MAX_ITEMS = 50;

async function cleanupCache() {
  const cache = await caches.open(CACHE_NAME);
  const keys = await cache.keys();
  
  // Remove old entries
  const now = Date.now();
  
  for (const request of keys) {
    const response = await cache.match(request);
    const dateHeader = response?.headers.get('date');
    
    if (dateHeader) {
      const age = now - new Date(dateHeader).getTime();
      
      if (age > MAX_AGE) {
        await cache.delete(request);
      }
    }
  }
  
  // Remove excess entries (LRU)
  const remainingKeys = await cache.keys();
  if (remainingKeys.length > MAX_ITEMS) {
    const toDelete = remainingKeys.slice(0, remainingKeys.length - MAX_ITEMS);
    await Promise.all(toDelete.map(key => cache.delete(key)));
  }
}

self.addEventListener('activate', (event) => {
  event.waitUntil(cleanupCache());
});

Versioning and Updates

const VERSION = 'v1.2.0';
const CACHE_NAME = `app-${VERSION}`;

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

Workbox (Google's SW Library)

npm install workbox-webpack-plugin
// next.config.js (with Workbox)
const WorkboxPlugin = require('workbox-webpack-plugin');

module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.plugins.push(
        new WorkboxPlugin.GenerateSW({
          clientsClaim: true,
          skipWaiting: true,
          runtimeCaching: [
            {
              urlPattern: /^https:\/\/api\.example\.com\/.*/,
              handler: 'NetworkFirst',
              options: {
                cacheName: 'api-cache',
                expiration: {
                  maxEntries: 50,
                  maxAgeSeconds: 300, // 5 minutes
                },
              },
            },
            {
              urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
              handler: 'CacheFirst',
              options: {
                cacheName: 'image-cache',
                expiration: {
                  maxEntries: 60,
                  maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
                },
              },
            },
            {
              urlPattern: /\.(?:js|css)$/,
              handler: 'StaleWhileRevalidate',
              options: {
                cacheName: 'static-resources',
              },
            },
          ],
        })
      );
    }
    return config;
  },
};

Workbox Strategies

import { registerRoute } from 'workbox-routing';
import {
  NetworkFirst,
  CacheFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// API calls
registerRoute(
  /^https:\/\/api\.example\.com\/.*/,
  new NetworkFirst({
    cacheName: 'api',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60, // 5 minutes
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200],
      }),
    ],
  })
);

// Images
registerRoute(
  /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

// Fonts
registerRoute(
  /\.(?:woff|woff2|ttf|otf)$/,
  new CacheFirst({
    cacheName: 'fonts',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 30,
        maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
      }),
    ],
  })
);

Communication with Service Worker

From App to SW

// Send message to service worker
if (navigator.serviceWorker.controller) {
  navigator.serviceWorker.controller.postMessage({
    type: 'SKIP_WAITING',
  });
}

// Listen for response
navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data.type === 'SW_UPDATED') {
    showUpdateBanner();
  }
});

From SW to App

// sw.js
self.addEventListener('message', (event) => {
  if (event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

// Notify all clients
self.clients.matchAll().then((clients) => {
  clients.forEach((client) => {
    client.postMessage({
      type: 'SW_UPDATED',
    });
  });
});

Testing Service Workers

describe('Service Worker', () => {
  it('should register service worker', async () => {
    const registration = await navigator.serviceWorker.register('/sw.js');
    expect(registration).toBeTruthy();
  });
  
  it('should cache static assets', async () => {
    const cache = await caches.open('static-v1');
    const response = await cache.match('/');
    expect(response).toBeTruthy();
  });
  
  it('should serve from cache when offline', async () => {
    // Simulate offline
    await page.setOfflineMode(true);
    
    const response = await page.goto('/');
    expect(response.status()).toBe(200);
  });
});

Best Practices

  1. Version your caches: Use cache names with versions
  2. Clean old caches: Delete old versions on activate
  3. Selective caching: Don't cache everything
  4. Network timeout: Add timeouts to network requests
  5. Offline fallback: Always have offline page
  6. Update notifications: Tell users when update available
  7. skipWaiting carefully: Can break active pages
  8. Test offline: Test app works without network
  9. Monitor errors: Track SW errors in production
  10. Use Workbox: For production apps

Common Pitfalls

Caching everything: Wastes storage
Selective caching by route/type

No cache versioning: Old content sticks
Version cache names, clean old ones

No offline fallback: Broken experience
Always cache offline page

skipWaiting() immediately: Breaks active tabs
Notify user, let them decide

No update mechanism: Users stuck on old version
Check for updates, notify user

Service Workers are powerful—use the right caching strategy for each resource type!

On this page