A minimal cache-first offline service worker
Published March 2, 2027
const CACHE_NAME = "app-shell-v1";
const PRECACHE_URLS = ["/", "/styles.css", "/app.js", "/offline.html"];
self.addEventListener("install", (event) => {
(event as Event & { waitUntil: (p: Promise<unknown>) => void }).waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)),
);
});
self.addEventListener("fetch", (event) => {
const fetchEvent = event as Event & { request: Request; respondWith: (r: Promise<Response> | Response) => void };
fetchEvent.respondWith(caches.match(fetchEvent.request).then((cached) => cached ?? fetch(fetchEvent.request)));
});What
This service worker precaches a fixed list of app-shell files when it installs, then answers every fetch from that cache first, only reaching the network if the requested URL isn't cached.
Why it matters
A page with no service worker has no offline story at all — lose the connection mid-navigation and the browser shows its own error page. A "cache first" strategy is the simplest fix that's still correct: once the app shell is cached, the app itself loads even with no network, and only the actual data requests need connectivity. It's the right default for files that don't change on every deploy (a versioned bundle, a logo, an offline fallback page) — the wrong choice for anything that must always be fresh, which is why real apps mix this with other strategies per route rather than applying it to everything.
How it works
self.addEventListener("install", ...)fires once, the first time this worker is registered (or when its script content changes).caches.open()thencache.addAll(PRECACHE_URLS)fetches every listed URL and stores the responses underCACHE_NAME, all before the worker is allowed to activate.- This project's
tsconfig.jsononly includes thedomlib, notwebworker, soServiceWorkerGlobalScope,ExtendableEvent, andFetchEventaren't ambient types here. Theevent as Event & { waitUntil: ... }andevent as Event & { request: ...; respondWith: ... }casts describe just the real runtime shape the callback needs, so the file typechecks without a separate worker-scoped tsconfig. waitUntil(promise)tells the browser not to finish installing until precaching completes — skip it and the worker could activate before the app shell is actually cached.- The
fetchhandler callscaches.match(fetchEvent.request)and falls back tofetch(fetchEvent.request)with??when nothing matches — a cached response short-circuits the network entirely; a miss behaves exactly like there was no service worker for that one request.
Gotchas
fetchEvent.respondWithand.waitUntilare real methods on the event instance, not free functions — destructuring them out (const { respondWith } = fetchEvent) and calling the bare reference later throwsIllegal invocation, because the browser's internal implementation checks the receiver. Always call them asfetchEvent.respondWith(...).- This version never writes network responses back into the cache and never removes old cache versions on
activate. That's fine for a fixed, versioned app shell (bumpCACHE_NAMEand redeploy the worker to invalidate everything at once) but isn't a general-purpose caching layer — see the Cache API post for caching arbitrary responses at runtime. - A new service worker version stays "waiting" until every tab using the old one closes, unless you call
self.skipWaiting()— without it, users can be stuck on stale cached assets for a surprisingly long time after a deploy.
Related
- The Cache API — the storage primitive this worker calls directly; useful outside a service worker too, from regular page scripts.
- Workbox — a library that generates this same install/fetch scaffolding, plus the other standard strategies (stale-while-revalidate, network-first), once "cache first" alone stops covering every route.