This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive

A Pragmatic Service Worker: Cache Strategy, Offline, No Abuse (80 Lines, No Drama)

The case against most service workers

A Pragmatic Service Worker: Cache Strategy, Offline, No Abuse (80 Lines, No Drama)

Article content

The case against most service workers

Service workers are the feature most likely to end up on a "cool feature I added and then forgot about" list. The tradeoffs are real:

  • A bug in your SW can break the site for returning visitors in ways that are hard to debug and slow to fix.
  • A greedy caching strategy can serve stale content to users who would prefer fresh.
  • Registering an SW at all commits you to a lifecycle - updates, skip-waiting, claim - that requires careful thought.

Given all that, why ship one? For a blog: the offline page. For a PWA: the install experience and the background resilience. For dout.dev, both.

What I refuse to do is cache everything, intercept every request, and ship an SW that tries to be a runtime framework. The dout.dev SW is about 80 lines. This post is what is in it.

The scope

Three jobs, in order of importance.

  1. Serve an offline fallback page when the network is unreachable and the user requests a page that is not in cache.
  2. Cache the critical shell - the home page, the main CSS, the primary script bundle - so the next visit is instant even on a cold network.
  3. Cache visited posts on a stale-while-revalidate basis, so re-reading a post is instant and returning to it offline works.

Everything else - images, feeds, analytics beacons, third-party assets - is not intercepted.

The precache list

At install time, the SW precaches exactly the files needed to render the offline experience. That list is generated by the build and inlined into the SW file, so there is no manifest to drift.

const PRECACHE = 'dout-precache-v1';
const RUNTIME = 'dout-runtime-v1';

const PRECACHE_URLS = ['/', '/offline.html', '/styles/index.css', '/scripts/main.js', '/assets/favicon.svg'];

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

The precache name includes a version (-v1). Bumping that version on a release invalidates the precache cleanly.

The activate cleanup

On activation, old caches get deleted. Without this, users accumulate dead caches forever.

self.addEventListener('activate', (event) => {
  const valid = new Set([PRECACHE, RUNTIME]);
  event.waitUntil(
    caches.keys().then((names) => Promise.all(names.filter((n) => !valid.has(n)).map((n) => caches.delete(n))))
  );
  self.clients.claim();
});

self.clients.claim() takes control of existing tabs on the first activation, so the new SW is in charge immediately instead of after a full reload. That is a judgment call - some projects prefer to wait for a reload to avoid mid-session inconsistencies. For a read-only blog, claiming is safe.

The fetch handler, in three cases

The fetch handler has three branches, and each is small.

1. HTML navigation requests

For navigation (a page load), try the network first. If the network wins, cache the response for later. If the network fails, serve the cached version, and if that is missing, the offline page.

self.addEventListener('fetch', (event) => {
  const req = event.request;
  if (req.mode === 'navigate') {
    event.respondWith(
      fetch(req)
        .then((res) => {
          const copy = res.clone();
          caches.open(RUNTIME).then((cache) => cache.put(req, copy));
          return res;
        })
        .catch(() => caches.match(req).then((cached) => cached || caches.match('/offline.html')))
    );
    return;
  }
  // ...
});

Network-first for HTML means readers always get the freshest post when they are online. The cache is a fallback, not a source of truth.

2. Same-origin static assets

For CSS, JS, and fonts on the same origin, cache-first with a background revalidate. This is the classic stale-while-revalidate pattern:

if (req.destination === 'style' || req.destination === 'script' || req.destination === 'font') {
  event.respondWith(
    caches.match(req).then((cached) => {
      const networkFetch = fetch(req).then((res) => {
        const copy = res.clone();
        caches.open(RUNTIME).then((cache) => cache.put(req, copy));
        return res;
      });
      return cached || networkFetch;
    })
  );
  return;
}

Returning cached content immediately keeps the page fast; the background fetch updates the cache for next time. The trade-off is that the first load after a deploy serves the old bundle, and the fresh one is picked up on the next navigation. For a blog, that is acceptable.

3. Everything else

Images, feeds, third-party URLs, analytics beacons - pass through to the network without touching the cache. The SW explicitly does not intercept.

// fall through to the network

Not caching something is a decision too. It avoids the trap of caching things you never meant to cache and then not being able to invalidate them.

The offline page

offline.html is a static page with the branding and a short message. It links to the home (which might be cached) and includes a small retry button that reloads the page.

The only trick: the offline page must not reference uncached resources. If the offline CSS is not in the precache, the page still renders but unstyled. The precache list above includes the main CSS, so this works.

Update strategy

The SW updates itself when the browser fetches /sw.js and notices it differs byte-for-byte from the registered one. Because the precache list contains a version string, a deploy that bumps the precache version triggers an update.

For a blog, that is enough. I do not ship an explicit "update available" banner. The user gets the new SW on the next navigation after a deploy, and the cleanup handler deletes the old cache.

What I did not add

  • Background sync. The blog has nothing to sync. Readers do not post content.
  • Push notifications. Reader-initiated subscriptions belong to the RSS layer.
  • Periodic background sync. Same reason.
  • Navigation preload. A legitimate optimization, but it adds complexity I did not need for the current page load times.

Each of these is a feature I could add later without restructuring the SW. Keeping the current one small is the point.

The takeaway

A service worker on a blog is worth the 80 lines if you ship the three jobs: offline fallback, shell precache, and a conservative runtime cache for repeat visits. Resist the urge to intercept every request. The bugs you avoid by caching less are worth more than the performance gains from caching more.

References

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.