Devlog #19 — Going Offline-First: Service Workers, Cache Strategies & Lighthouse 100

3D Simulations is now fully offline-capable. Any simulation you've visited loads instantly from cache — even without internet. Here's the complete technical story: why we chose certain cache strategies, how we precache simulation bundles, and every step it took to score 100/100 on Lighthouse's PWA audit.

0 ms
repeat navigation time
(from cache)
100
Lighthouse PWA
score
200+
simulation pages
precacheable

Why Offline-First?

Physics simulations are compute-heavy pages: Three.js, custom WASM modules, and simulation workers are often 200–800 KB per page. On a slow or intermittent connection, each visit incurs a painful initial download. Browser caching via Cache-Control headers helps, but a service worker layer gives us full control over which resources are cached, when, and how stale they're allowed to be.

Service Worker Lifecycle

A service worker is a script that the browser installs once and then runs as a persistent background proxy. Its lifecycle has three key phases: install (precache critical assets), activate (clean up old caches), and fetch (intercept network requests).

// sw.js — install: precache the shell and shared assets
const PRECACHE = 'shell-v4';
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/offline.html',
  '/shared/theme.css',
  '/shared/components.css',
  '/shared/components.js',
  '/manifest.json',
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(() => self.skipWaiting())  // activate immediately
  );
});

Cache Strategies by Resource Type

Not all resources should be cached the same way. We use four strategies:

Cache-First
Serve from cache if available; only go to network on a miss. Best for versioned assets whose content never changes at the same URL (Three.js bundle, WASM, fonts).
Stale-While-Revalidate
Serve from cache immediately (zero-latency), then fetch a fresh copy from the network in the background for next time. Used for HTML pages and the shared component CSS.
Network-First
Try the network first; fall back to cache on failure. Used for the blog index and category pages where stale content would mislead users.
Network-Only
Never cache. Used for the RSS feed, sitemap.xml, and analytics endpoints where stale data is meaningless.
// sw.js — fetch: route to correct strategy
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  // Skip non-GET and cross-origin
  if (request.method !== 'GET' || url.origin !== location.origin) return;

  // Versioned assets → cache-first
  if (/\.(wasm|js|css)$/.test(url.pathname) && /[?&]v=/.test(url.search)) {
    event.respondWith(cacheFirst(request));
    return;
  }

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

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

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  const cache = await caches.open('assets-v4');
  cache.put(request, response.clone());
  return response;
}

Runtime Precaching of Simulation Pages

Simulations are too large to precache all at once on install — that would consume several hundred megabytes of cache storage and delay the service worker install. Instead, we precache a simulation's assets the first time a user visits it, so that the second visit is instant.

A dedicated SIM_CACHE has a max-age policy: entries older than 7 days are evicted on the next activate event using a timestamp metadata store in IndexedDB.

The Install Prompt

When the browser's installability criteria are met (HTTPS + service worker + manifest with icons + display: standalone), it fires a beforeinstallprompt event. We capture this and show a tasteful install banner in our footer:

let deferredPrompt;
window.addEventListener('beforeinstallprompt', e => {
  e.preventDefault();            // stop browser's default mini-infobar
  deferredPrompt = e;
  showInstallBanner();
});

function showInstallBanner() {
  const banner = document.getElementById('install-banner');
  if (!banner) return;
  banner.hidden = false;
  banner.querySelector('button').addEventListener('click', async () => {
    banner.hidden = true;
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log('Install outcome:', outcome);  // 'accepted' or 'dismissed'
    deferredPrompt = null;
  });
}

Lighthouse PWA Score 100 — The Checklist

Cache storage limits: Browsers typically grant up to 60% of available disk space for origin storage (which includes IndexedDB, CacheStorage, and localStorage). On a device with 32 GB storage and 15 GB free, that's ~9 GB. We stay well under by evicting sim assets older than 7 days and capping each simulation's cached assets to ~5 MB.