Swoff Swoff

SW Template

Versioned service worker template with progress tracking and offline-first caching.

Save the code below as swoff/sw-template.js — the following patterns build on it.

Overview

dist/
├── sw-v1.0.0.js      ← Versioned SW
└── version.json      ← Version manifest

Build the Service Worker

Create swoff/sw-template.js and append each snippet in order. Lines with // [[...]] are placeholders replaced by the build script.

Placeholders & Configuration

let CACHE_NAME = "";
let ASSETS_TO_CACHE = [];

// [[CACHE_NAME]]
// [[ASSETS_LIST]]

const AUTO_SKIP_WAITING = false;
const CACHE_NAME_RUNTIME = "swoff-runtime";
const TAG_DB_NAME = "swoff-cache-tags";
const TAG_STORE_NAME = "tags";

// [[CACHE_NAME]] and // [[ASSETS_LIST]] are replaced by the build script (see Build Scripts). // [[AUTO_SKIP_WAITING]] is also generated by the CLI based on the autoActivate config flag. AUTO_SKIP_WAITING — set to true to activate new versions immediately. CACHE_NAME_RUNTIME — a separate cache for API responses that persists across SW updates. TAG_DB_NAME / TAG_STORE_NAME — IndexedDB database for cache tag indexing, used by the invalidation system.

Install — Download with Progress

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      let downloaded = 0;
      for (const url of ASSETS_TO_CACHE) {
        try {
          const request = new Request(url);
          await cache.add(request);
          downloaded++;
          const percent = Math.round(
            (downloaded / ASSETS_TO_CACHE.length) * 100,
          );
          const clients = await self.clients.matchAll({
            includeUncontrolled: true,
          });
          clients.forEach((client) => {
            client.postMessage({
              type: "SW_PROGRESS",
              percent,
              downloaded,
              total: ASSETS_TO_CACHE.length,
            });
          });
        } catch (err) {
          console.error(`Failed to cache ${url}:`, err);
        }
      }
      if (AUTO_SKIP_WAITING) self.skipWaiting();
    })(),
  );
});

Iterates assets one-by-one, reports progress via SW_PROGRESS postMessage after each success. Auto-activates if AUTO_SKIP_WAITING is true.

Activate — Clean Up Old Caches

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((keys) =>
        Promise.all(
          keys
            .filter((key) => key !== CACHE_NAME && key !== CACHE_NAME_RUNTIME)
            .map((key) => caches.delete(key)),
        ),
      ),
  );
});

Deletes old version caches. Keeps CACHE_NAME_RUNTIME so cached API data survives SW updates.

Message — Skip Waiting, Cache Invalidation, and Runtime Cache

self.addEventListener("message", (event) => {
  if (event.data.type === "SKIP_WAITING") {
    self.skipWaiting();
  }
  if (event.data.type === "INVALIDATE_TAG" && event.data.tag) {
    event.waitUntil(invalidateByTag(event.data.tag));
  }
  if (event.data.type === "CLEAR_RUNTIME_CACHE") {
    event.waitUntil(
      (async () => {
        const cache = await caches.open("swoff-runtime");
        const keys = await cache.keys();
        await Promise.all(keys.map((r) => cache.delete(r)));
      })(),
    );
  }
});

Client sends SKIP_WAITING after user approves an update (see Client Registration). Client sends INVALIDATE_TAG after a mutation syncs, to flush stale cached responses with the given tag. CLEAR_RUNTIME_CACHE is sent on logout when auth is enabled, clearing cached API responses.

Fetch — Cache Strategies

Three caching strategies: Cache-First (default), Stale-While-Revalidate, and Network-First.

The strategy is set by the client via the X-SW-Stale header (see API Integration).

Read Detection

function isReadRequest(request) {
  const strategy = request.headers.get("X-SW-Cache-Strategy");
  if (strategy === "read") return true;
  if (strategy === "mutation") return false;
  return request.method === "GET" || request.method === "HEAD";
}

Stale-While-Revalidate

Serve cached data instantly, refresh in the background. Best for data that changes infrequently but needs to appear instant.

async function staleWhileRevalidate(event, request) {
  const runtimeCache = await caches.open(CACHE_NAME_RUNTIME);
  const cached = await runtimeCache.match(request);

  if (cached) {
    event.waitUntil(refreshCache(runtimeCache, request));
    return cached;
  }

  const response = await fetch(request);
  if (response.ok) {
    await runtimeCache.put(request, response.clone());
    const tagsHeader = request.headers.get("X-SW-Cache-Tags");
    if (tagsHeader) {
      const url = new URL(request.url).href;
      const tags = tagsHeader.split(",").map((t) => t.trim());
      await cacheTagUrl(url, tags);
    }
  }
  return response;
}

async function refreshCache(cache, request) {
  try {
    const response = await fetch(request);
    if (response.ok) {
      await cache.put(request, response.clone());
      const tagsHeader = request.headers.get("X-SW-Cache-Tags");
      if (tagsHeader) {
        const url = new URL(request.url).href;
        const tags = tagsHeader.split(",").map((t) => t.trim());
        await cacheTagUrl(url, tags);
      }
    }
  } catch {
    // Background refresh failed — stale cache remains usable
  }
}

Cache-First (Default)

self.addEventListener("fetch", (event) => {
  if (!isReadRequest(event.request)) return;

  if (event.request.headers.get("X-SW-Stale") === "true") {
    event.respondWith(staleWhileRevalidate(event, event.request));
    return;
  }

  event.respondWith(
    (async () => {
      const runtimeCache = await caches.open(CACHE_NAME_RUNTIME);

      const byRequest = await runtimeCache.match(event.request);
      if (byRequest) return byRequest;

      if (event.request.mode === "navigate") {
        const spa = await cache.match("/index.html");
        if (spa) return spa;
      }

      const response = await fetch(event.request);
      if (response.ok) {
        const cloned = response.clone();
        event.waitUntil(
          (async () => {
            await runtimeCache.put(event.request, cloned);
            const tagsHeader = event.request.headers.get("X-SW-Cache-Tags");
            if (tagsHeader) {
              const url = new URL(event.request.url).href;
              const tags = tagsHeader.split(",").map((t) => t.trim());
              await cacheTagUrl(url, tags);
            }
          })(),
        );
      }
      return response;
    })(),
  );
});

isReadRequest checks the X-SW-Cache-Strategy header (set by the client, see API Integration), then falls back to method. Mutations bypass the SW entirely. When X-SW-Stale: true is set, the SW serves cached data immediately and refreshes in the background. Without it, the default cache-first strategy is used: runtime cache by full request, SPA /index.html for navigations, network with cache-on-success. In both modes, the X-SW-Cache-Tags header is stored (see tag management) so selective invalidation works after mutations sync.

The SW does not replace network errors with custom messages. If fetch() rejects (network failure, DNS error, CORS, etc.) and there is no cached fallback for that strategy, the error propagates to the client as a standard TypeError — the same as if no SW were present. This lets client code distinguish real failures from offline states.

Tag Management

These functions manage the tag-to-URL index used by INVALIDATE_TAG:

function openTagDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(TAG_DB_NAME, 1);
    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains(TAG_STORE_NAME)) {
        const store = db.createObjectStore(TAG_STORE_NAME, { keyPath: "url" });
        store.createIndex("by-tag", "tags", { multiEntry: true });
      }
    };
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

async function cacheTagUrl(url, tags) {
  const db = await openTagDB();
  const tx = db.transaction(TAG_STORE_NAME, "readwrite");
  const store = tx.objectStore(TAG_STORE_NAME);
  store.put({ url, tags });
  await new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function invalidateByTag(tag) {
  const db = await openTagDB();
  const tx = db.transaction(TAG_STORE_NAME, "readonly");
  const store = tx.objectStore(TAG_STORE_NAME);
  const index = store.index("by-tag");
  const entries = await new Promise((resolve, reject) => {
    const request = index.getAll(tag);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
  await db.close();

  const runtimeCache = await caches.open(CACHE_NAME_RUNTIME);
  for (const entry of entries) {
    await runtimeCache.delete(entry.url);
  }

  // Remove entries from tag index
  const writeDb = await openTagDB();
  const writeTx = writeDb.transaction(TAG_STORE_NAME, "readwrite");
  const writeStore = writeTx.objectStore(TAG_STORE_NAME);
  for (const entry of entries) {
    writeStore.delete(entry.url);
  }
  await new Promise((resolve, reject) => {
    writeTx.oncomplete = () => resolve();
    writeTx.onerror = () => reject(writeTx.error);
  });

  // Notify all clients
  const clients = await self.clients.matchAll();
  clients.forEach((client) => {
    client.postMessage({ type: "TAG_INVALIDATED", tag });
  });
}

Security Considerations

Subresource Integrity

The SW caches all assets at install time via ASSETS_TO_CACHE + cache.addAll(). If your build pipeline or CDN is compromised, the SW will cache and serve malicious content — even offline. Consider adding an integrity check after caching by verifying a hash of each cached response against a known value. Alternatively, pin your asset versions and use lockfiles in your build process.

Runtime Cache Persists Across SW Updates

The swoff-runtime cache (for API responses) is intentionally preserved when the SW updates — this is what keeps cached data available after an update. However, it also means a poisoned API response (e.g., from a supply-chain attack) will survive SW updates. It is only cleared by:

  • Calling invalidateByTag(tag) from client code
  • The activate event cleaning old version caches (runtime cache is exempt)
  • The user manually clearing site data

To mitigate this, call invalidateByTag() aggressively after mutations, and consider periodically trimming the runtime cache (see Cache Trimming).

Events

EventSourceDetailPurpose
sw-version-detectedmain.tsxnoneVersion info available on window
sw-update-availablemain.tsx{ version: string }New version on server
sw-progressmain.tsx (from SW){ percent: number }Download progress
sw-readymain.tsxnoneSW active and controlling page
sw-errormain.tsxnoneSW registration failed
TAG_INVALIDATEDSW (postMessage){ tag: string }Cache entries with tag cleared — broadcast to all tabs for cross-tab sync
CLEAR_RUNTIME_CACHEClient (postMessage)noneClears runtime cache on logout (auth-enabled builds only)
BACKGROUND_SYNC_COMPLETESW (postMessage){ succeeded, failed, tags }Background sync result — triggers mutation-sync-complete event on clients

Storage

KeyTypePurpose
localStorage['swRegisteredVersion']stringTracks registered SW version
sessionStorage['sw-dismissed-update']string ("true")Suppresses update dialog for session
swoff-cache-tags (IDB)object storeURL-to-tags index for invalidation

Next Steps

On this page