Service workers that don’t surprise you: deterministic caching for offline-first PWAs

Published: (February 3, 2026 at 12:18 PM EST)
4 min read
Source: Dev.to

Source: Dev.to

Series:
Start here · Part 1 · Part 2 · Part 3 · Part 4 · Part 5 · Part 6 · Part 7 · Part 8 · Part 9 · Part 10

This post is Part 3 in a Dev.to series grounded in the open‑source Pain Tracker repo.

Not medical advice.
Not a compliance claim.

It’s about deterministic behavior and truthful boundaries.

If you want privacy‑first, offline health tech to exist without surveillance funding it, sponsor the build → https://paintracker.ca/sponsor.

If you haven’t read Part 2 yet:

  • Part 2: Three storage layers (state cache vs offline DB vs encrypted vault)

What “surprising” service workers do

If you’ve been burned by service workers, it’s usually one of these (and if you haven’t been burned yet: congrats—your day is coming).

  1. Stale HTML breaks your app after deploy

    • The browser keeps serving an old index.html.
    • The module graph changes → you get chunk 404s or a blank screen.
  2. Base paths don’t match reality

    • Works on / but breaks on GitHub Pages under /pain-tracker/.
    • You register the wrong scope and nothing behaves as you expect.
  3. Caching becomes accidental data retention

    • You didn’t intend to cache responses.
    • You cache “everything” because it’s easy.
    • User‑specific payloads end up in caches (a problem for sensitive apps).
  4. Updates are confusing

    • New worker installs but doesn’t activate.
    • Users don’t refresh.
    • You can’t tell what version is running.

The theme: the browser is doing exactly what you asked… but you didn’t ask carefully.

Pain Tracker’s service worker philosophy

  • Network‑first for navigations (avoid stale HTML)
  • Cache static assets only (scripts, styles, images, fonts)
  • Versioned caches with cleanup on activation
  • Small precache for offline.html and the manifest

There’s no “offline magic,” no runtime caching of arbitrary API responses, and no attempt to make the SW a data layer.

Two service‑worker scripts exist

ScopeURL
Root (normal deploy)
GitHub Pages base path (/pain-tracker/)

The most important line in both workers is conceptually this: navigations are network‑first.

In public/sw.js a navigation request is detected using request.mode === 'navigate' or Accept: text/html. Then it does:

try {
  return fetch(request);
} catch (_) {
  return caches.match('/offline.html');
}

That’s it.

Why this matters

If you cache navigations with a cache‑first strategy, you’ll eventually ship a new build whose HTML points to new chunk filenames. The cached HTML keeps pointing at the old filenames → you get chunk 404s and the app feels “randomly broken.”

For a health‑adjacent PWA, that kind of failure is worse than a clean offline message.

What gets cached

Pain Tracker only caches same‑origin GET requests that look like static assets:

  • Allow‑listed path prefixes: /assets/, /icons/, /logos/, /screenshots/ (plus the /pain-tracker/ equivalents for the GitHub Pages build).
  • Conservative extension allow‑list: .js, .css, .png, .svg, .woff2, …

Cache flow

  1. If the request is in the cache → return it.
  2. Otherwise → fetch it, cache successful 200 responses, then return it.

Resulting offline strategy

  • The shell loads quickly after the first visit.
  • Deploys don’t get stuck behind cached HTML.
  • Sensitive runtime responses aren’t accidentally cached.

Versioning & cleanup

Both workers have a version string and build the cache name from it:

const CACHE_NAME = `pain-tracker-static-v${VERSION}`;

On activation the SW deletes older caches with the pain-tracker- prefix, giving you a clean invariant:

  • Bump the SW version → old caches are removed.
  • No guessing, no “maybe it’ll update.” It either updates or it doesn’t.

GitHub‑Pages‑style worker (public/pain-tracker/sw.js)

What changes:

  • Pre‑cache URL for the manifest becomes /pain-tracker/manifest.json.
  • Static prefixes include /pain-tracker/assets/.
  • Offline fallback includes /pain-tracker/.

This duplication prevents hours of “why is it offline on one environment but not the other?”

Registering the SW in the app

The registration lives in src/utils/pwa-utils.ts:

// https://github.com/CrisisCore-Systems/pain-tracker/blob/main/src/utils/pwa-utils.ts

Key behaviors

  1. Computes a baseUrl from VITE_BASE (when set), or Vite’s BASE_URL, falling back to location.pathname when Vite gives a relative base.
  2. Registers ${baseUrl}sw.js with scope: baseUrl.
  3. Sets updateViaCache: 'none' to force update checks.
  4. Wires an updatefound listener so the app can notify users when new content is available.

The service worker posts a SW_READY message on activation and responds to PING with PONG. The PWA manager listens for that and sets window.__pwa_sw_ready = true.

Practical trick

  • Avoids flaky “is the SW ready yet?” tests.
  • Gives you a simple debug signal in DevTools.

What it does not do (and why)

  • Does not cache arbitrary fetches.
  • Does not cache API responses.
  • Does not cache navigations.
  • Does not implement a “sync your health data to the cloud” system.

These aren’t missing features; they’re deliberate boundaries.

If you add more SW features later (background sync, offline processing, etc.), treat them as new trust boundaries:

  • Decide what data is allowed in caches.
  • Avoid storing Class A payloads in Cache Storage.
  • Be explicit about what can happen while “locked” vs. “unlocked”.

Quick testing checklist

  1. Inspect the service worker script
  2. In DevTools
    • Application → Service Workers – confirm scope + script URL.
    • Application → Cache Storage – look for pain-tracker-static-v….
  3. Test offline behavior
    • Network → Offline.
    • Refresh a route.
    • You should get the offline fallback instead of a broken shell.

What’s next?

  • Part 4 will cover defensive parsing (Zod + schema‑first inputs) and how to keep “offline‑first” from becoming “silently accepts garbage.”

Prev: Part 2 — Three storage layers
Next: Part 4 — Zod + defensive parsing

https://paintracker.ca/sponsor

/paintracker.ca/sponsor

Star the repo (secondary):
https://github.com/CrisisCore-Systems/pain-tracker

Read the full series from the start:
link

Back to Blog

Related posts

Read more »

You don't need CSS preprocessor

CSS Pre‑processors: Are They Still Worth It? There was a time when CSS preprocessors seemed like a magical elixir for any CSS problems. It was only necessary to...