This page is a focused deep-dive under Cache Warming & Pre-Fetching on Reconnect, which covers the broader reconnection recovery patterns for Progressive Web Apps.

The exact failure scenario #

An abrupt network drop — mobile radio switching towers, a VPN renegotiation, or a flapping Wi-Fi association — leaves the Service Worker runtime cache in a partially hydrated state. The user comes back online. The online event fires. The hydration layer in the main thread reads from the cache and renders whatever it finds. If the cache holds responses written mid-stream or payloads whose ETags no longer match origin, the app renders stale or inconsistent UI without any visible error. The problem is silent: no exception is thrown, no boundary fires, the hydration simply commits bad state.

The fix is an atomic warming cycle: detect reconnection, fetch fresh payloads into a staging namespace, validate checksums, and promote atomically — all before the hydration layer is allowed to read.

Cache warming state machine after a network drop State flow diagram showing five states: Stale Cache, Staging Fetch, Validating, Committed, and Rolled Back, with transitions between them annotated with the triggering condition. Stale Cache online event Staging Fetch open _staging Validating SHA-256 check Committed atomic swap Rolled Back delete staging ok fail

Zero-to-working implementation #

The snippet below is a complete, self-contained warming module. It runs inside the Service Worker and is triggered by a message from the main thread on reconnect. Drop it into your sw.ts and register the message listener.

// sw.ts — atomic cache warming after a network drop

const ACTIVE_CACHE = 'pwa-v2';
const STAGING_SUFFIX = '_staging';

type WarmingState =
  | 'idle'
  | 'fetching'
  | 'validating'
  | 'committed'
  | 'rolled_back';

interface WarmingResult {
  state: WarmingState;
  error?: string;
}

async function sha256Hex(buffer: ArrayBuffer): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', buffer);
  return Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');
}

export async function atomicCacheWarm(
  urls: string[],
  activeCacheName = ACTIVE_CACHE
): Promise<WarmingResult> {
  const stagingName = `${activeCacheName}${STAGING_SUFFIX}`;
  let state: WarmingState = 'fetching';

  try {
    // 1. Fetch all URLs concurrently into the staging namespace.
    //    Each response is cloned: one copy for checksum, one for caching.
    const staging = await caches.open(stagingName);
    const fetched = await Promise.all(
      urls.map(async (url) => {
        const res = await fetch(url, { cache: 'no-store' });
        if (!res.ok) throw new Error(`Bad status ${res.status} for ${url}`);
        return { url, res };
      })
    );

    state = 'validating';

    // 2. Validate each payload via SHA-256 before writing to staging.
    for (const { url, res } of fetched) {
      const cloned = res.clone();
      const buffer = await cloned.arrayBuffer();
      const hash = await sha256Hex(buffer);

      // Compare against a known-good hash stored in IndexedDB (optional).
      // If no stored hash exists, accept and record this as the baseline.
      const expectedHash = await getStoredHash(url); // your IDB helper
      if (expectedHash && expectedHash !== hash) {
        throw new Error(`Checksum mismatch for ${url}`);
      }
      await staging.put(url, res);
      await storeHash(url, hash); // persist hash for next warm cycle
    }

    // 3. Promote staging to active atomically:
    //    copy each entry into the live cache, then discard staging.
    const active = await caches.open(activeCacheName);
    const keys = await staging.keys();
    await Promise.all(
      keys.map(async (req) => {
        const val = await staging.match(req);
        if (val) await active.put(req, val);
      })
    );
    await caches.delete(stagingName);

    state = 'committed';
    return { state };
  } catch (err) {
    state = 'rolled_back';
    await caches.delete(stagingName); // leave active cache untouched
    return { state, error: (err as Error).message };
  }
}

// Message bridge — main thread posts { type: 'WARM_CACHE', urls: string[] }
self.addEventListener('message', (event: ExtendableMessageEvent) => {
  if (event.data?.type !== 'WARM_CACHE') return;
  event.waitUntil(
    atomicCacheWarm(event.data.urls).then((result) => {
      event.source?.postMessage({ type: 'WARM_CACHE_DONE', result });
    })
  );
});

And the main-thread trigger, placed in your app’s connectivity restore handler:

// app.ts — trigger warm cycle when connectivity is restored
function onReconnect(priorityUrls: string[]): void {
  navigator.serviceWorker.controller?.postMessage({
    type: 'WARM_CACHE',
    urls: priorityUrls,
  });
}

window.addEventListener('online', () => {
  onReconnect([
    '/api/user/profile',
    '/api/app/config',
    '/api/notifications/unread',
  ]);
});

Step-by-step walkthrough #

  1. fetch(url, { cache: 'no-store' }) — bypasses the HTTP cache so reconnection always pulls a genuine fresh response from origin, not a browser-level stale copy.

  2. res.clone() before arrayBuffer() — a Response body is a readable stream that can only be consumed once. Clone first or the stream is exhausted before cache.put() can write it.

  3. SHA-256 checksum comparisongetStoredHash(url) reads from IndexedDB; if no hash is stored yet, the first warm cycle establishes the baseline. Subsequent drops catch tampered or truncated payloads before they enter the active cache.

  4. Staging-to-active copy via Promise.all — writing into _staging first means the active cache is never in a partial state. The active cache is only touched after all entries are validated and ready. This is the closest approximation to an atomic swap available in the Cache API; for stricter atomicity, keep a current-cache-name pointer in IndexedDB and update the pointer only after the new cache is fully populated.

  5. caches.delete(stagingName) in both success and catch paths — orphaned _staging namespaces accumulate storage. The catch branch rolls back by deleting staging and returning the active cache untouched; the last-known-good state survives intact.

  6. event.waitUntil(...) in the message listener — wrapping the async work in waitUntil tells the browser not to kill the Service Worker mid-cycle, even if the initiating tab is backgrounded or suspended.

Edge cases #

Scenario Symptom Mitigation
Partial body delivery mid-stream arrayBuffer() resolves with a truncated buffer; checksum mismatches and warm cycle rolls back The rollback path catches this automatically; no extra guard needed
Storage quota exceeded during staging write cache.put() throws QuotaExceededError Wrap staging.put() in a try/catch; call navigator.storage.estimate() before the loop and evict LRU entries if usage exceeds 80 %
Rapid online/offline flapping triggering duplicate warm cycles Multiple WARM_CACHE messages queued while a cycle is already running Track an isWarming flag in the Service Worker scope; ignore incoming messages while it is true
Cross-origin redirect poisoning the staging cache fetch() follows a redirect to a different origin; response.url differs from the requested URL Check response.url === url (or within allowlist) before calling staging.put(); abort on mismatch

Verification steps #

DevTools Application panel — open Chrome DevTools → Application → Cache Storage. After triggering a reconnect, confirm that pwa-v2_staging does not persist (it should be absent or deleted within milliseconds of commit). The pwa-v2 cache should contain fresh Date header timestamps.

Service Worker console log — add a temporary console.log('[warm]', result) after atomicCacheWarm() to see the WarmingResult object. A healthy run logs { state: 'committed' }; a checksum failure logs { state: 'rolled_back', error: '...' }.

Playwright assertion — in your integration test, restore network access and then assert the IndexedDB hash store contains valid hashes for every priority URL:

// playwright test
await page.route('**/api/**', route => route.fulfill({ status: 200, body: '{}' }));
await page.evaluate(() => window.dispatchEvent(new Event('online')));
// wait for WARM_CACHE_DONE message
const result = await page.evaluate(() =>
  new Promise<{ state: string }>((resolve) => {
    navigator.serviceWorker.addEventListener('message', (e) => {
      if (e.data?.type === 'WARM_CACHE_DONE') resolve(e.data.result);
    });
  })
);
expect(result.state).toBe('committed');

Lighthouse PWA audit — a successful warm cycle means subsequent navigations (even simulated offline ones in DevTools) serve fully hydrated responses. The Lighthouse “Works offline” audit should pass without uncached-resource warnings.

Frequently Asked Questions #

How do I prevent Service Worker memory bloat during aggressive cache warming?

Process URLs in batches of three to five rather than firing every fetch() concurrently. Hold only transient buffer references inside the validation loop — once staging.put() resolves, the buffer reference goes out of scope and V8 can reclaim it. Monitor performance.memory.usedJSHeapSize from the main thread via a periodic postMessage and pause the warm cycle if it exceeds a threshold.

What is the safest rollback procedure when cache warming fails mid-flight?

The catch branch in atomicCacheWarm already handles this: it calls caches.delete(stagingName) and returns { state: 'rolled_back' }, leaving the active cache completely unchanged. The main thread listens for WARM_CACHE_DONE and, on a rolled-back result, can fall back to the hydration mismatch recovery patterns — rendering from the existing stale cache rather than blocking the UI.

How do I reproduce network drop scenarios reliably in CI?

Use Playwright’s page.route() to abort all API requests, let the PWA initialize with a stale cache, then restore routes and dispatch a synthetic online event. Assert the final WarmingResult.state from the WARM_CACHE_DONE Service Worker message rather than relying on timing. This is deterministic regardless of CI network conditions.