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.
- Basic JavaScript (async/await, promises, fetch)
- Understanding of browser caching at a conceptual level
-
A local server (Live Server extension,
npx serve) — SW requires HTTPS or localhost
Service Worker Lifecycle
Understanding the lifecycle prevents the #1 SW confusion: why aren't my changes showing?
-
Register — main page calls
navigator.serviceWorker.register('/sw.js'). The browser downloads and parses the SW file. -
Install — SW fires
installevent. Use this to pre-cache assets. The SW is "installed" but not yet "active". -
Wait — if an old SW is still controlling pages, the
new SW waits. Call
self.skipWaiting()to skip this. -
Activate — SW takes control. Use this to clean up
old caches. Call
clients.claim()to take control of existing pages immediately. - Fetch — SW intercepts every network request from controlled pages. You decide: serve from cache, fetch from network, or both.
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.