10LOC
Web Platformadvanced

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() then cache.addAll(PRECACHE_URLS) fetches every listed URL and stores the responses under CACHE_NAME, all before the worker is allowed to activate.
  • This project's tsconfig.json only includes the dom lib, not webworker, so ServiceWorkerGlobalScope, ExtendableEvent, and FetchEvent aren't ambient types here. The event as Event & { waitUntil: ... } and event 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 fetch handler calls caches.match(fetchEvent.request) and falls back to fetch(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.respondWith and .waitUntil are real methods on the event instance, not free functions — destructuring them out (const { respondWith } = fetchEvent) and calling the bare reference later throws Illegal invocation, because the browser's internal implementation checks the receiver. Always call them as fetchEvent.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 (bump CACHE_NAME and 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.