Skip to main content

Service Workers: Lifecycle, Caching Strategies, and Offline Support

Created: September 25, 2018 Larry Qu 6 min read

Introduction

app and the network — enabling offline support, background sync, push notifications, and performance improvements through caching.

What Service Workers Can Do

  • Offline support — serve cached content when the network is unavailable
  • Cache management — control exactly what gets cached and for how long
  • Background sync — defer actions until connectivity is restored
  • Push notifications — receive messages even when the app isn’t open
  • Performance — serve assets from cache instead of the network

Service Worker Lifecycle

Register → Download → Install → Activate → Idle → Fetch/Message/Push

er** — your page registers the SW file 2. Download — browser downloads the SW script 3. Install — SW installs, install event fires (cache assets here) 4. Waiting — new SW waits for old SW to release clients 5. Activate — SW activates, activate event fires (clean old caches here) 6. Idle — SW waits for events 7. Terminated — browser may terminate idle SWs to save memory

Registering a Service Worker

// main.js — register in your main page script
if ('serviceWorker' in navigator) {
    window.addEventListener('load', async () => {
        try {
            const registration = await navigator.serviceWorker.register('/sw.js', {
                scope: '/'  // controls which pages the SW manages
            });

            console.log('SW registered, scope:', registration.scope);

            // Listen for updates
            registration.addEventListener('updatefound', () => {
                const newSW = registration.installing;
e', () => {
                    console.log('SW state:', newSW.state);
                    // states: installing → installed → activating → activated
                });
            });

        } catch (error) {
            console.error('SW registration failed:', error);
        }
    });
}

Scope

The scope determines which pages the SW controls. /sw.js at the root controls all pages. /posts/sw.js only controls pages under /posts/:

// Only controls /posts/* pages
navigatviceWorker.register('/posts/sw.js', { scope: '/posts/' });

The Install Event: Pre-caching

Cache critical assets during installation:

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

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then((cache) => {
                co assets');
                return cache.addAll(PRECACHE_ASSETS);
            })
            .then(() => self.skipWaiting())  // activate immediately
    );
});

event.waitUntil() keeps the SW alive until the promise resolves. self.skipWaiting() forces the new SW to activate without waiting for old clients to close.

The Activate Event: Cache Cleanup

Remove old caches when a new SW activates:

self.addEventListener('activate', (event) => {
    const CURRENT_CACHES = [CACHE_NAME];

 t.waitUntil(
        caches.keys()
            .then((cacheNames) =>
                Promise.all(
                    cacheNames
                        .filter((name) => !CURRENT_CACHES.includes(name))
                        .map((name) => {
                            console.log('Deleting old cache:', name);
                            return caches.delete(name);
                        })
                )
            )
            .then(() => self.clients.claim())  // take control of all clients
    );
});

self.clients.claim() makes the SW take control of all open pages immediately.

The Fetch Event: Caching Strategies

The fetch event intercepts all network requests. Choose a strategy based on the resource type:

Strategy 1: Cache First (Static Assets)

| fetch(event.request))event.s


## Debugging Service Workers

### Chrome DevTools

1. DevTools → Application → Service Workers
2. Check "Update on reload" during development
3. Click "Unregister" to remove the SW
4. Use "Bypass for network" to skip the SW temporarily

### Inspecting Caches

// In DevTools console const cacheNames = await caches.keys(); console.log(cacheNames);

const cache = await caches.open(‘myapp-v1’); const keys = await cache.keys(); keys.forEach(req => console.log(req.url));

## Resource// Cache images for 30 days
registerRoute(
    ({ request }) => request.destination === 'image',
    new CacheFirst({
        cacheName: 'images',
        plugins: [new ExpirationPlugin({ maxAgeSeconds: 30 * 24 * 60 * 60 })]
    })
);

// Network first for API
registerRoute(
    ({ url }) => url.pathname.startsWith('/api/'),
    new NetworkFirst({ cacheName: 'api-cache' })
);

// Stale while revalidate for pages
registerRoute(
    ({ request }) => request.mode === 'navigate',
    new StaleWhileRevalidate({ cacheName:ogle's library that simplifies service worker development:

npm install workbox-webpack-plugin

or for Vite:

npm install vite-plugin-pwa

// sw.js with Workbox import { precacheAndRoute } from ‘workbox-precaching’; import { registerRoute } from ‘workbox-routing’; import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from ‘workbox-strategies’; import { ExpirationPlugin } from ‘workbox-expiration’;

// Pre-cache assets (injected by build tool) precacheAndRoute(self.__WB_MANIFEST);

ATED’ }); }); });

// Service Worker → Specific Client (the sender) self.addEventListener(‘message’, (event) => { if (event.data.type === ‘PING’) { event.source.postMessage({ type: ‘PONG’ }); } });

// Page listening for SW messages navigator.serviceWorker.addEventListener(‘message’, (event) => { if (event.data.type === ‘CACHE_UPDATED’) { showUpdateBanner(); } });

## Using Workbox (Recommended for Production)

[Workbox](https://developer.chrome.com/docs/workbox/) is Go          method: 'POST',
                        body: JSON.stringify(item)
                    }).then(() => removeFromIndexedDB(item.id))
                ))
            )
        );
    }
});

Messaging Between Page and Service Worker

// Page → Service Worker
navigator.serviceWorker.controller?.postMessage({
    type: 'SKIP_WAITING'
});

// Service Worker → All Clients
self.clients.matchAll().then((clients) => {
    clients.forEach((client) => {
        client.postMessage({ type: 'CACHE_UPDgify(data) });
    } catch {
        // Store for later and register sync
        await saveToIndexedDB(data);
        const registration = await navigator.serviceWorker.ready;
        await registration.sync.register('submit-form');
    }
}

// In sw.js
self.addEventListener('sync', (event) => {
    if (event.tag === 'submit-form') {
        event.waitUntil(
            getFromIndexedDB().then((items) =>
                Promise.all(items.map((item) =>
                    fetch('/api/submit', {
              e when the network is unavailable:

self.addEventListener(‘fetch’, (event) => { if (event.request.headers.get(‘accept’)?.includes(’text/html’)) { event.respondWith( fetch(event.request) .catch(() => caches.match(’/offline.html’)) ); } });

## Background Sync

Defer actions until connectivity is restored:

// In your page async function submitForm(data) { try { await fetch(’/api/submit’, { method: ‘POST’, body: JSON.strinME); cache.put(request, response.clone()); return response; } catch { return caches.match(request); } }

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

## Offline Fallback Page

Show a custom offline pag
    if (event.request.headers.get('accept')?.includes('text/html')) {
        event.respondWith(staleWhileRevalidate(event.request));
        return;
    }

    // Default: network first
    event.respondWith(networkFirst(event.request));
});

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

async function networkFirst(request) {
    try {
        const response = await fetch(request);
        const cache = await caches.open(CACHE_NA);
});

Complete Strategy Router

self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    // Static assets: cache first
    if (url.pathname.match(/\.(css|js|png|jpg|svg|woff2)$/)) {
        event.respondWith(cacheFirst(event.request));
        return;
    }

    // API calls: network first
    if (url.pathname.startsWith('/api/')) {
        event.respondWith(networkFirst(event.request));
        return;
    }

    // HTML pages: stale while revalidate const networkFetch = fetch(event.request).then((response) => {
                    cache.put(event.request, response.clone());
                    return response;
                });
                return cached || networkFetch;  // return cache immediately if available
            })
        )
    );
});

Strategy 4: Cache Only (Offline-First)

Only serve from cache — never hit the network:

self.addEventListener('fetch', (event) => {
    event.respondWith(caches.match(event.request)request, clone));
                    return response;
                })
                .catch(() => caches.match(event.request))  // fall back to cache
        );
    }
});

Strategy 3: Stale While Revalidate (Best for Most Content)

Serve from cache immediately, update cache in background:

self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.open(CACHE_NAME).then((cache) =>
            cache.match(event.request).then((cached) => {
               
    );
});

Strategy 2: Network First (API Calls)

Try network, fall back to cache. Best for dynamic data:

self.addEventListener('fetch', (event) => {
    if (event.request.url.includes('/api/')) {
        event.respondWith(
            fetch(event.request)
                .then((response) => {
                    // Cache the fresh response
                    const clone = response.clone();
                    caches.open(CACHE_NAME).then((cache) => cache.put(
Serve from cache, fall back to network. Best for versioned assets (CSS, JS, images):

self.addEventListener(‘fetch’, (event) => { event.respondWith( caches.match(event.request) .then((cached) => cached |

Resources

Comments

Share this article

Scan to read on mobile