DEV Community

Cover image for Stale-while-revalidate with Cache.match(): fast answers, safe refreshes
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

Stale-while-revalidate with Cache.match(): fast answers, safe refreshes

Summary

Use Cache.match() (or an equivalent cacheStore.match) to return the last known response immediately, refresh the resource in the background, and only tell the client after the new copy is safely written. That preserves responsiveness and prevents the UI from claiming freshness before the cache actually changes.

Implementation overview (server will insert verified code)

  • Fast path: call cacheStore.match(url). If a value exists, return it immediately and kick off a background update. Expose the background task as a promise so tests or diagnostics can await it without delaying the UI.
  • Miss path: when the cache has no entry, fetch from the network, store the result, notify clients after the write, and return the network response.
  • Background update: fetch the candidate, re-read the current cached entry, compare freshness (timestamp, ETag, or version), and only write the candidate when it is at least as fresh. If it’s older, skip the write and emit a skipped notification. Notify clients only after successful writes.

Expected behavior

  • Immediate rendering when cache entry exists.
  • Non-blocking refresh so future navigations see a fresher copy.
  • Notifications are trustworthy: the client receives an “updated” signal only after the write completes.
  • No last-writer-wins surprises: the compare-and-set rule prevents older network responses from replacing newer cached copies.

Limitations

  • The runtime models one canonical cached response per URL. It doesn’t merge multiple candidates or provide full HTTP validation semantics.
  • It’s a small, testable object model; it does not exercise browser-specific APIs like event.respondWith(), Response.clone(), or worker activation and lifecycle. Those require real browser integration tests.
  • Freshness checks are application-specific. The example uses a synthetic timestamp; in production, use version headers, ETags, or application metadata to decide writes.

Why read the full article

It explains the exact ordering you must preserve, shows a minimal runtime that makes ownership and replacement rules visible, and includes focused tests proving the three core behaviors: immediate cache returns, network fallback storing, and refusing to overwrite newer cache entries with older network responses.

(Verified implementation will be inserted below by the server.)

Working example

export function createMemoryCache() {
  const store = new Map();

  return {
    async match(url) {
      const value = store.get(url);
      return value ? { ...value } : undefined;
    },
    async put(url, response) {
      store.set(url, { ...response });
    },
    async delete(url) {
      return store.delete(url);
    },
    async keys() {
      return [...store.keys()];
    },
  };
}

export function createSwRuntime({ cacheStore, fetchFn, notifyClients }) {
  if (!cacheStore || typeof cacheStore.match !== 'function' || typeof cacheStore.put !== 'function') {
    throw new TypeError('cacheStore must provide async match(url) and put(url, response)');
  }
  if (typeof fetchFn !== 'function') {
    throw new TypeError('fetchFn must be a function');
  }
  if (typeof notifyClients !== 'function') {
    throw new TypeError('notifyClients must be a function');
  }

  async function backgroundUpdate(url) {
    try {
      const networkResponse = await fetchFn(url);
      const existing = await cacheStore.match(url);
      if (!existing || networkResponse.ts >= existing.ts) {
        await cacheStore.put(url, networkResponse);
        notifyClients({ url, type: 'updated', response: { ...networkResponse } });
      } else {
        notifyClients({ url, type: 'skipped-old', response: { ...networkResponse } });
      }
    } catch (error) {
      notifyClients({ url, type: 'update-failed', error: String(error) });
    }
  }

  async function handleFetch(url, { offlineFallback } = {}) {
    const cached = await cacheStore.match(url);
    if (cached) {
      const background = backgroundUpdate(url);
      return {
        source: 'cache',
        response: { ...cached },
        background,
      };
    }

    try {
      const networkResponse = await fetchFn(url);
      await cacheStore.put(url, networkResponse);
      notifyClients({ url, type: 'updated', response: { ...networkResponse } });
      return {
        source: 'network',
        response: { ...networkResponse },
      };
    } catch {
      if (offlineFallback) {
        const fallback = await cacheStore.match(offlineFallback);
        if (fallback) {
          return {
            source: 'fallback',
            response: { ...fallback },
          };
        }
      }

      return {
        source: 'error',
        response: { body: 'offline', ts: Date.now(), offline: true },
      };
    }
  }

  return { handleFetch };
}
Enter fullscreen mode Exit fullscreen mode

Read the full article: https://chriseugenerodriguez.com/blog/service-worker-stale-while-revalidate-cache-match

Top comments (0)