(from cache)
score
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:
// 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
-
Service worker with offline fallback — our
offline.htmlis served from cache when network fails and no cached page exists. -
Web App Manifest —
manifest.jsonwith 192px and 512px icons,theme_color,background_color,display: standalone, andstart_url. -
HTTPS everywhere — GitHub Pages provides this
automatically on the
mysimulator.ukcustom domain. - No mixed content — all fonts, scripts, and images are self-hosted or served over HTTPS CDN with SRI hashes.
- Maskable icon — a maskable icon entry in the manifest with a safe zone so the icon renders correctly on every Android launcher shape.
- Fast first load — critical CSS is inlined; JS is deferred; Three.js lazy-loads inside each simulation iframe so it doesn't block the page shell LCP.
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.