The PWA Was Fast Until Monday Morning: A 2026 Web Development Playbook for Navigation Preload and Safe Service Worker Updates

Illustration of service worker navigation preload improving PWA page load after deploy

At 8:42 on a Monday morning, our “offline-ready” PWA looked fine in synthetic checks, yet support tickets said the homepage was hanging on a white screen for a few seconds after login. Not always. Not for everyone. Just enough users to hurt trust.

What made this bug painful was that Lighthouse stayed green. CDN logs looked healthy. Even API latency was normal. The issue lived in the handoff between browser navigation, service worker startup, and cache strategy after a fresh deploy. Once we treated that handoff as a first-class production path, the problem became repeatable and fixable.

This guide is a practical, production-focused playbook for using service worker navigation preload with safer cache directives and update mechanics. If your PWA occasionally feels “randomly slow after deploy,” this is likely your missing piece.

Why this failure mode is sneaky

A service worker does not process fetch events until it is running. On cold start, that boot time adds delay before your network request begins. The web.dev navigation preload guidance calls this out directly, with startup often around tens of milliseconds on desktop and potentially much higher on constrained mobile devices.

That delay is usually invisible when you serve from cache. But if your navigation path is network-first (often correct for HTML), startup delay plus network RTT can stack into a visible blank state. This is where navigation preload helps by starting the network request in parallel with service worker startup.

The architecture that worked in production

We switched from “single strategy everywhere” to route-aware behavior:

  • HTML navigations: network-first with preload, cache fallback.
  • Versioned static assets: long-lived immutable caching.
  • API JSON: short freshness + stale-while-revalidate where acceptable.
  • Service worker updates: explicit user prompt and deterministic takeover.

If this sounds similar to broader rollback discipline, it maps well to our existing frontend deployment lessons from import maps and safe rollbacks. The point is consistency: navigation, assets, and updates must agree on version behavior.

Core implementation: preload + network-first navigation

Start by enabling preload during activate, then consume event.preloadResponse before issuing a fresh fetch.

// sw.js
self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    if (self.registration.navigationPreload) {
      await self.registration.navigationPreload.enable();
      await self.registration.navigationPreload.setHeaderValue('nav-preload-v1');
    }
    await self.clients.claim();
  })());
});

self.addEventListener('fetch', (event) => {
  const req = event.request;

  // Handle only document navigations here.
  if (req.mode !== 'navigate') return;

  event.respondWith((async () => {
    const cache = await caches.open('html-v3');

    try {
      // 1) Use preload if browser already started request in parallel.
      const preload = await event.preloadResponse;
      if (preload) {
        cache.put(req, preload.clone());
        return preload;
      }

      // 2) Otherwise do normal network fetch.
      const network = await fetch(req);
      cache.put(req, network.clone());
      return network;
    } catch (err) {
      // 3) Fallback to last known good HTML.
      const cached = await cache.match(req);
      if (cached) return cached;
      return cache.match('/offline.html');
    }
  })());
});

Two details matter here:

  • Use navigation preload only for navigation requests, not all fetches.
  • If your server varies preload behavior, return Vary: Service-Worker-Navigation-Preload to keep caches correct.

Cache headers that avoid accidental staleness fights

MDN’s Cache-Control reference is still the best practical baseline: use explicit directives, separate personalized from shared content, and avoid “mystery freshness.”

# HTML documents: revalidate quickly, do not pin for long
location ~* \.html$ {
  add_header Cache-Control "no-cache, must-revalidate" always;
}

# Fingerprinted assets: cache aggressively and immutable
location ~* \.(js|css|woff2|png|jpg|svg)$ {
  add_header Cache-Control "public, max-age=31536000, immutable" always;
}

# If your origin changes response for preload requests
add_header Vary "Service-Worker-Navigation-Preload" always;

In other words, avoid serving old HTML that references new bundles you have not activated yet. That exact mismatch pattern is also behind many “works locally, fails in prod” hydration symptoms, similar to what we covered in the React hydration mismatch debugging playbook.

Safe service worker updates (without surprise reload chaos)

Workbox guidance is clear: don’t force takeover blindly. Detect waiting workers, ask the user at a safe moment, then switch deterministically.

// app shell (main thread)
import { Workbox } from 'https://storage.googleapis.com/workbox-cdn/releases/7.1.0/workbox-window.prod.mjs';

if ('serviceWorker' in navigator) {
  const wb = new Workbox('/sw.js');

  wb.addEventListener('waiting', async () => {
    const ok = await showUpdateToastAndAskUser();
    if (!ok) return;

    wb.addEventListener('controlling', () => {
      window.location.reload();
    });

    wb.messageSkipWaiting();
  });

  wb.register();
}

This “prompt then promote” flow dramatically reduced weird half-updated states. It also pairs nicely with strict security headers and policy hygiene from our strict CSP rollout notes and CORS preflight performance guidance, because frontend reliability and security now share the same deploy boundaries.

Tradeoffs you should decide explicitly

  • Freshness vs resilience: network-first HTML gives fresher content but can fail hard without fallback cache.
  • Aggressive updates vs user continuity: immediate activation can fix bugs fast but may interrupt active flows.
  • Single response path vs preload variants: custom preload responses can be faster, but require correct Vary and cache behavior.
  • Simplicity vs observability: richer strategies need instrumentation (cold-start rate, preload hit rate, waiting-worker count).

Troubleshooting: when preload is enabled but users are still slow

1) event.preloadResponse is always undefined

  • Confirm request is a GET navigation (req.mode === 'navigate').
  • Confirm registration.navigationPreload.enable() was called in activate.
  • Check browser support and that service worker scope includes the route.

2) Intermittent wrong HTML version after deploy

  • Check HTML cache headers. immutable on HTML is usually wrong.
  • Ensure asset URLs are content-hashed.
  • Verify old worker is not serving cached shell while new assets are already live.

3) Custom preload path works locally but breaks behind CDN

  • Set Vary: Service-Worker-Navigation-Preload on responses that differ.
  • Validate CDN caching rules for request headers and normalization.
  • Inspect edge cache key settings to avoid response mixups.

FAQ

Q1) Should I enable navigation preload for every fetch?

No. It is designed for navigation requests. Applying the same logic to API and asset fetches usually adds complexity without user-visible benefit.

Q2) Does navigation preload replace normal caching strategy?

No. It complements it. Preload reduces startup-induced delay, while your cache strategy still decides freshness, offline behavior, and rollback safety.

Q3) Is stale-while-revalidate safe for HTML?

Usually not as a default for app HTML. It can mask deploy mismatches. It is often safer for non-critical API or content responses where brief staleness is acceptable.

Actionable takeaways for this week

  • Enable service worker navigation preload and consume event.preloadResponse for HTML navigations.
  • Audit Cache-Control separately for HTML, versioned assets, and API responses.
  • Add Vary: Service-Worker-Navigation-Preload if preload responses differ from normal navigation responses.
  • Implement a user-visible “new version available” flow instead of forced silent takeover.
  • Track cold starts, preload hit rate, and waiting-worker duration in your frontend telemetry.

If your PWA occasionally feels haunted after a deploy, start with these four checks before rewriting architecture. Most teams do not need a new framework, they need a cleaner navigation contract between browser, worker, and origin.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

Privacy Policy · Contact · Sitemap

© 7Tech – Programming and Tech Tutorials