Service Worker Recipes Playground

Interactive guide to caching strategies, lifecycle, and update patterns

Cache First

Best Performance Offline Ready

Check the cache first. If a cached response exists, serve it immediately. Only go to the network if the resource isn't in the cache. This is ideal for assets that rarely change — fonts, images, CSS/JS with versioned URLs.

Request Flow

App Request
Service Worker
Cache
?
Hit: Serve
Miss
Network
Fetch & Cache
FontsGoogle Fonts, icon fonts — never change once loaded
Versioned Assetsapp.abc123.js — URL changes on update
ImagesProduct images, avatars with hash URLs

Advantages

  • Fastest response time
  • Works offline for cached resources
  • Reduces server load

Trade-offs

  • Can serve stale content
  • Need a cache-busting strategy
  • Initial request still hits network
sw.js — Cache First Strategy
// Cache First: prioritize cache, fallback to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        return cached; // Cache hit — serve immediately
      }

      // Cache miss — fetch from network and cache the response
      return fetch(event.request).then((response) => {
        // Only cache valid responses
        if (!response || response.status !== 200 || response.type !== 'basic') {
          return response;
        }

        const responseToCache = response.clone();
        caches.open('cache-first-v1').then((cache) => {
          cache.put(event.request, responseToCache);
        });

        return response;
      });
    })
  );
});

Network First

Always Fresh Offline Fallback

Always try the network first. If the network responds, cache the result and serve it. If the network fails (offline, timeout), fall back to the cached version. Best for content that needs to be up-to-date — API responses, news feeds, user profiles.

Request Flow

App Request
Service Worker
Network
?
Serve & Cache
Fail
Cache Fallback
Serve Stale
API ResponsesUser data, dashboard stats — freshness matters
News/Social FeedsContent updates frequently
E-commerce PricesMust show current pricing when possible

Advantages

  • Always serves latest content when online
  • Graceful offline fallback
  • Users see fresh data by default

Trade-offs

  • Slower than Cache First (network latency)
  • Falls back to stale data offline
  • Network timeout needs careful tuning
sw.js — Network First Strategy
// Network First: prioritize fresh data, fallback to cache
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Network success — cache and serve
        if (response && response.status === 200) {
          const responseToCache = response.clone();
          caches.open('network-first-v1').then((cache) => {
            cache.put(event.request, responseToCache);
          });
        }
        return response;
      })
      .catch(() => {
        // Network failed — try cache
        return caches.match(event.request);
      })
  );
});

// With timeout: race network against a timer
function networkFirstWithTimeout(request, timeout = 3000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      caches.match(request).then(resolve);
    }, timeout);

    fetch(request).then((response) => {
      clearTimeout(timer);
      const clone = response.clone();
      caches.open('network-first-v1').then((c) => c.put(request, clone));
      resolve(response);
    }).catch(() => {
      clearTimeout(timer);
      caches.match(request).then(resolve);
    });
  });
}

Stale-While-Revalidate

Best Balance Fast

Serve the cached version immediately (fast!), but simultaneously fetch the latest version from the network and update the cache in the background. The user sees stale content now but gets fresh content on the next visit. Great for content that updates but isn't time-critical.

Request Flow (Parallel)

App Request
Service Worker
Cache
Serve Now
Meanwhile...
Network
Update Cache
Blog PostsShow cached version, update in background
User AvatarsFast display, eventual consistency
Non-critical CSS/JSAnalytics scripts, third-party widgets

Advantages

  • Instant response from cache
  • Content stays relatively fresh
  • Best of both worlds

Trade-offs

  • User sees stale content on first load
  • Background fetch uses bandwidth
  • Race condition awareness needed
sw.js — Stale-While-Revalidate
// Stale-While-Revalidate: fast response + background update
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('swr-v1').then((cache) => {
      return cache.match(event.request).then((cached) => {
        // Always fetch in background to update cache
        const fetchPromise = fetch(event.request).then((response) => {
          if (response && response.status === 200) {
            cache.put(event.request, response.clone());
          }
          return response;
        });

        // Return cache immediately, or wait for network if no cache
        return cached || fetchPromise;
      });
    })
  );
});

// With max-age: only revalidate if cache is older than threshold
function swrWithMaxAge(request, maxAgeMs = 60000) {
  return caches.open('swr-v1').then((cache) => {
    return cache.match(request).then((cached) => {
      const cachedDate = cached?.headers.get('sw-cache-date');
      const isStale = !cachedDate ||
        (Date.now() - new Date(cachedDate).getTime()) > maxAgeMs;

      if (isStale) {
        // Background revalidate
        fetch(request).then((resp) => {
          const headers = new Headers(resp.headers);
          headers.set('sw-cache-date', new Date().toISOString());
          return resp.blob().then((body) => {
            cache.put(request, new Response(body, { headers }));
          });
        });
      }

      return cached || fetch(request);
    });
  });
}

Cache Only

Fastest Fully Offline

Only serve from the cache. Never go to the network for this request. The resources must have been pre-cached during the install phase. This is the simplest strategy and guarantees offline availability for known assets.

Request Flow

App Request
Service Worker
Cache
Serve

Network is never contacted. Resources must be pre-cached.

App ShellHTML skeleton pre-cached at install
Offline PageA dedicated /offline.html fallback
Critical AssetsCore CSS/JS that must always be available

Advantages

  • Zero network dependency
  • Instant, predictable responses
  • Perfect for app shell architecture

Trade-offs

  • Content can't update without SW update
  • Must know all URLs upfront
  • Fails if resource wasn't pre-cached
sw.js — Cache Only with Pre-caching
const CACHE_NAME = 'cache-only-v1';
const PRE_CACHE_URLS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/offline.html',
];

// Pre-cache resources during install
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(PRE_CACHE_URLS);
    })
  );
});

// Cache Only: serve exclusively from cache
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        return cached;
      }
      // No fallback — return a 503 response
      return new Response('Resource not available offline', {
        status: 503,
        statusText: 'Service Unavailable',
      });
    })
  );
});

Network Only

Always Fresh Simplest

Always go to the network. Never use cache. This is equivalent to having no Service Worker for this request, but you can still add logging, analytics, or request modification. Use for requests that must always be live — login, checkout, real-time data.

Request Flow

App Request
Service Worker
Network
Serve

Cache is bypassed entirely. Fails offline.

AuthenticationLogin/logout must always be live
PaymentsCheckout flows require real-time verification
Real-time DataWebSocket connections, live scores

Advantages

  • Always fresh, no stale data risk
  • Simplest to implement
  • Good for sensitive operations

Trade-offs

  • No offline support
  • Full network latency every time
  • Could just not intercept the request
sw.js — Network Only
// Network Only: always fetch from network
self.addEventListener('fetch', (event) => {
  // Option 1: explicit network only
  event.respondWith(fetch(event.request));
});

// Option 2: selective — only intercept certain paths
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);

  // Skip caching for API and auth requests
  if (url.pathname.startsWith('/api/') ||
      url.pathname.startsWith('/auth/')) {
    event.respondWith(fetch(event.request));
    return;
  }

  // Use a different strategy for other requests...
  event.respondWith(
    caches.match(event.request).then((c) => c || fetch(event.request))
  );
});

// Option 3: network only with logging
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).then((response) => {
      console.log(`[SW] Fetched: ${event.request.url} (${response.status})`);
      return response;
    })
  );
});

Registration Lifecycle

A Service Worker goes through a specific lifecycle: registration → installation → waiting → activation. Understanding this lifecycle is crucial for handling updates correctly and avoiding unexpected behavior.

1

Registration

The browser downloads and parses the SW script. Registration triggers the install event.

2

Installing

The install event fires. Pre-cache resources here. If any resource fails to cache, installation fails and the SW is discarded.

3

Waiting

If another SW is already active, the new one waits here. It won't activate until all pages controlled by the old SW are closed. Use skipWaiting() to bypass.

4

Activated

The activate event fires. Clean up old caches here. After activation, the SW controls all pages in its scope.

5

Controlling

The SW now intercepts fetch, push, and sync events. It stays active until replaced by a new version or unregistered.

main.js — Registration
// Register a service worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const reg = await navigator.serviceWorker.register('/sw.js', {
        scope: '/',
      });

      // Track lifecycle state
      if (reg.installing) {
        console.log('Service Worker: installing');
      } else if (reg.waiting) {
        console.log('Service Worker: waiting (update available)');
      } else if (reg.active) {
        console.log('Service Worker: active');
      }

      // Listen for state changes
      reg.addEventListener('updatefound', () => {
        const newWorker = reg.installing;
        newWorker.addEventListener('statechange', () => {
          console.log(`SW state: ${newWorker.state}`);
        });
      });
    } catch (err) {
      console.error('SW registration failed:', err);
    }
  });
}
sw.js — Install & Activate Events
const CACHE = 'app-v1';
const PRECACHE = ['/', '/index.html', '/app.css', '/app.js'];

// Install: pre-cache critical resources
self.addEventListener('install', (event) => {
  console.log('[SW] Installing...');
  event.waitUntil(
    caches.open(CACHE)
      .then((cache) => cache.addAll(PRECACHE))
      .then(() => console.log('[SW] Pre-cached resources'))
  );
});

// Activate: clean up old caches
self.addEventListener('activate', (event) => {
  console.log('[SW] Activating...');
  event.waitUntil(
    caches.keys().then((names) => {
      return Promise.all(
        names
          .filter((name) => name !== CACHE)
          .map((name) => {
            console.log(`[SW] Deleting old cache: ${name}`);
            return caches.delete(name);
          })
      );
    })
  );
});

Update Flow & skipWaiting

When you deploy a new version of your Service Worker, the browser detects the byte-level difference and starts an update. By default, the new SW waits until all tabs are closed. skipWaiting() forces immediate activation — powerful but needs careful handling.

Update Process

New SW Downloaded
Install Event
?
Default: Wait
All tabs closed
Activate
skipWaiting()
Activate Immediately
clients.claim()
sw.js — skipWaiting + clients.claim()
// Force immediate activation (use with caution)
self.addEventListener('install', (event) => {
  // Skip the waiting phase entirely
  self.skipWaiting();

  event.waitUntil(
    caches.open('app-v2').then((cache) => {
      return cache.addAll(['/', '/index.html', '/app.css', '/app.js']);
    })
  );
});

self.addEventListener('activate', (event) => {
  // Take control of all pages immediately
  // Without this, existing pages stay on the old SW
  event.waitUntil(clients.claim());

  // Clean up old caches
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((n) => n !== 'app-v2')
          .map((n) => caches.delete(n))
      )
    )
  );
});
main.js — Prompt User to Update
// Detect update and prompt user to reload
navigator.serviceWorker.register('/sw.js').then((reg) => {
  reg.addEventListener('updatefound', () => {
    const newWorker = reg.installing;

    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed' &&
          navigator.serviceWorker.controller) {
        // New SW installed but old one still active
        if (confirm('New version available! Reload?')) {
          newWorker.postMessage({ type: 'SKIP_WAITING' });
        }
      }
    });
  });
});

// Reload when the new SW takes over
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (!refreshing) {
    refreshing = true;
    window.location.reload();
  }
});

// In the SW: listen for SKIP_WAITING message
// self.addEventListener('message', (event) => {
//   if (event.data?.type === 'SKIP_WAITING') {
//     self.skipWaiting();
//   }
// });