SuperAPI AI Directory

service-worker-offline-first

General & other

View repository

Updated 10-15 21:40

About this skill

Service Worker Offline-First Patterns

Resilient web applications that work when the network doesn't.

Caching Strategy Selection

Strategy Latency Freshness Best For
Cache-First Fastest Stale risk Static assets, fonts, icons
Network-First Slower Always fresh API calls, dynamic content
Stale-While-Revalidate Fast Eventually fresh Semi-static content, user profiles
Network-Only Network-dependent Always fresh Authentication, real-time data
Cache-Only Fastest Frozen App shell, offline page

Service Worker Lifecycle

Install → waitUntil(cacheStaticAssets) → skipWaiting()
   ↓
Activate → waitUntil(cleanOldCaches) → clients.claim()
   ↓
Fetch → route to strategy by request type

Core Structure

const CACHE_VERSION = 'v1';
const STATIC_CACHE = `app-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `app-dynamic-${CACHE_VERSION}`;
const API_CACHE = `app-api-${CACHE_VERSION}`;

const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/manifest.json',
];

// Install - Cache static assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting();
});

// Activate - Clean up old caches
self.addEventListener('activate', (event) => {
  const currentCaches = [STATIC_CACHE, DYNAMIC_CACHE, API_CACHE];
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => !currentCaches.includes(key))
            .map((key) => caches.delete(key))
      )
    )
  );
  self.clients.claim();
});

// Fetch - Route to appropriate strategy
self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;

  const url = new URL(request.url);
  if (!url.protocol.startsWith('http')) return;

  if (isApiRequest(request)) {
    event.respondWith(networkFirstWithTimeout(request, API_CACHE, 5000));
  } else if (request.destination === 'image') {
    event.respondWith(cacheFirst(request, STATIC_CACHE));
  } else if (request.mode === 'navigate') {
    event.respondWith(networkFirstWithTimeout(request, DYNAMIC_CACHE, 3000));
  } else {
    event.respondWith(staleWhileRevalidate(request, DYNAMIC_CACHE));
  }
});

Caching Strategies

Cache-First with Expiry

async function cacheFirstWithExpiry(request, cacheName, maxAge) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  if (cached) {
    const cachedDate = new Date(cached.headers.get('sw-cached-date') || 0);
    if (Date.now() - cachedDate.getTime() < maxAge) {
      return cached; // Fresh cache hit
    }
  }

  try {
    const response = await fetch(request);
    if (response.ok) {
      const headers = new Headers(response.headers);
      headers.append('sw-cached-date', new Date().toISOString());
      const timestamped = new Response(response.clone().body, {
        status: response.status,
        statusText: response.statusText,
        headers,
      });
      await cache.put(request, timestamped);
    }
    return response;
  } catch {
    if (cached) return cached; // Return stale on network failure
    throw new Error('Network failed and no cache available');
  }
}

Network-First with Timeout

async function networkFirstWithTimeout(request, cacheName, timeout) {
  const cache = await caches.open(cacheName);

  try {
    const response = await Promise.race([
      fetch(request),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error('Network timeout')), timeout)
      ),
    ]);

    if (response.ok) {
      await cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await cache.match(request);
    if (cached) return cached;

    if (request.mode === 'navigate') {
      return caches.match('/offline.html');
    }
    return new Response(
      JSON.stringify({ error: 'Offline', cached: false }),
      { status: 503, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

Stale-While-Revalidate

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

  const fetchPromise = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });

  return cached || fetchPromise;
}

Background Sync

Queue failed mutations for retry when connectivity resumes:

self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-api-calls') {
    event.waitUntil(retryFailedRequests());
  }
});

async function retryFailedRequests() {
  // Read queued requests from IndexedDB
  const queue = await getQueuedRequests();
  const results = await Promise.allSettled(
    queue.map((req) => fetch(req.url, {
      method: req.method,
      headers: new Headers(req.headers),
      body: req.body,
    }))
  );

  // Remove successful, keep failed for next sync
  const remaining = queue.filter((_, i) =>
    results[i].status !== 'fulfilled' || !results[i].value.ok
  );
  await saveQueuedRequests(remaining);

  // Notify open tabs
  const clients = await self.clients.matchAll();
  clients.forEach((client) => {
    client.postMessage({ type: 'SYNC_COMPLETE', remaining: remaining.length });
  });
}

React Registration

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/sw.js')
      .then((registration) => {
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker?.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              showUpdateNotification(); // New version available
            }
          });
        });
      });
  });
}

Critical Rules

Rule Why
Always clean old caches on activate Storage bloats without cleanup
Use skipWaiting() + clients.claim() Immediate control of pages
Version bump cache names on deploy Forces fresh asset fetch
Only cache GET requests Mutations need idempotency guarantees
Provide offline fallback for navigation Users see blank page otherwise
Test in incognito Avoids stale SW from previous sessions

Common Pitfalls

Problem Solution
SW serves stale content forever Add cache expiry or version bumping
Infinite redirect loops Exclude auth endpoints from caching
Cache fills up device storage Set max cache size, prune old entries
SW blocks updates Use skipWaiting() to activate immediately
API data stale after offline Show "last updated" timestamp to users