Tutorial
⏱️ ~45 minutes 🎓 Intermediate 🛠️ Service Workers · PWA · JavaScript

Offline-First App with Service Workers

A Service Worker is a background JavaScript file that intercepts network requests, making it possible to serve your simulation from cache when there is no internet connection. This tutorial covers the SW lifecycle, cache-first and stale-while-revalidate strategies, the Web App Manifest for home-screen installability, and a practical comparison of vanilla SW vs Workbox.

Prerequisites

Service Worker Lifecycle

Understanding the lifecycle prevents the #1 SW confusion: why aren't my changes showing?

To force-update a SW during development: DevTools → Application → Service Workers → "Update on reload" and "Bypass for network". Or change the CACHE_VERSION constant to trigger install/activate.

Register the SW from Your Page

// In your main HTML or main JS file
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const reg = await navigator.serviceWorker.register('/sw.js', {
        scope: '/', // SW controls all pages under /
      });
      console.log('SW registered, scope:', reg.scope);

      // Optional: listen for updates
      reg.addEventListener('updatefound', () => {
        const newSW = reg.installing;
        newSW.addEventListener('statechange', () => {
          if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
            // New content available — show user a "Refresh" prompt
            console.log('New version available!');
          }
        });
      });
    } catch (err) {
      console.error('SW registration failed:', err);
    }
  });
}

Install Event — Precache Assets

On install, fetch and store every critical asset. The SW won't activate until all assets are cached successfully:

// sw.js
const CACHE_VERSION = 'v2'; // bump this to trigger update
const CACHE_NAME = `sim-cache-${CACHE_VERSION}`;

const PRECACHE_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/shared/theme.css',
  '/shared/components.css',
  '/shared/components.js',
  '/manifest.json',
  // Add your simulation HTML files:
  '/fluid/index.html',
  '/pathfinding/index.html',
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      console.log('Precaching assets...');
      return cache.addAll(PRECACHE_ASSETS);
    })
  );
  self.skipWaiting(); // Don't wait — take over immediately
});

Fetch Event — Cache-First Strategy

Cache-first: try the cache. If found, return immediately. If not, fetch from network and cache the response for next time. Best for static assets:

self.addEventListener('fetch', event => {
  // Only handle GET requests
  if (event.request.method !== 'GET') return;

  event.respondWith(cacheFirst(event.request));
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached; // ← serve from cache instantly

  try {
    const response = await fetch(request);
    if (response.ok) {
      // Cache a clone (responses can only be consumed once)
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    // Completely offline and not in cache
    return caches.match('/offline.html');
  }
}

Cache-First

Serve from cache. Fast, works offline. Stale until SW updates. Use for: JS, CSS, fonts.

Network-First

Try network. Fall back to cache on failure. Fresh when online, usable offline. Use for: API, HTML.

Stale-While-Revalidate

Serve cache immediately, then update cache in background. Always fast, eventually fresh. Use for: avatars, non-critical assets.

Network-Only

Always fetch. Never cache. Use for: analytics, payments, anything that must be fresh.

Activate — Clean Old Caches

When a new SW activates, delete caches from previous versions to free storage:

self.addEventListener('activate', event => {
  event.waitUntil(
    Promise.all([
      // Delete all caches except current version
      caches.keys().then(keys =>
        Promise.all(
          keys
            .filter(key => key !== CACHE_NAME)
            .map(key => {
              console.log('Deleting old cache:', key);
              return caches.delete(key);
            })
        )
      ),
      // Take control of all open tabs immediately
      self.clients.claim(),
    ])
  );
});

Stale-While-Revalidate Strategy

Serve the cached version instantly, then update the cache in the background. The user gets fast response and the next visit gets fresh content:

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

  // Revalidate in background regardless of cache hit
  const fetchPromise = fetch(request).then(response => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  }).catch(() => null); // ignore failure if offline

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

// Use different strategies for different request types:
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // CSS/JS: cache-first (versioned, rarely changes)
  if (url.pathname.match(/\.(css|js)$/)) {
    event.respondWith(cacheFirst(event.request));
    return;
  }

  // HTML pages: stale-while-revalidate
  if (event.request.headers.get('accept')?.includes('text/html')) {
    event.respondWith(staleWhileRevalidate(event.request));
    return;
  }

  // Everything else: network-first
  event.respondWith(networkFirst(event.request));
});

Web App Manifest — Make It Installable

With a valid manifest and a registered SW, Chrome/Edge show an "Install" prompt. The app can be added to the home screen and opens full-screen with no browser chrome:

// manifest.json
{
  "name": "3D Simulations",
  "short_name": "3DSims",
  "description": "Interactive physics and algorithm simulations",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a10",
  "theme_color": "#22c55e",
  "orientation": "any",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/icon-512-maskable.png", "sizes": "512x512",
      "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    { "src": "/screenshots/fluid.jpg", "sizes": "1280x720", "type": "image/jpeg",
      "form_factor": "wide" }
  ]
}
<!-- In <head> of every page: -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#22c55e" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />

PWA checklist: SW must be on HTTPS (or localhost), manifest must have valid icons, start_url must be in the SW scope. Check with Lighthouse (DevTools → Lighthouse → Progressive Web App) for full audit.

Continue Learning

🛠

Experiment in Playground

Test your simulation offline — write and run Three.js code directly in the browser.

Open Playground → View Simulation ↗