Skip to content

Service worker serves stale HTML/JS after deploy (navigateFallbackDenylist too narrow) — stuck old build on iOS PWA #47

Description

@goyamegh

Summary

The service worker can keep serving stale HTML/JS indefinitely after a deploy. A reload looks like it "did nothing" — the app stays on the old build until the service worker itself happens to update. This is especially visible on iOS Safari (installed PWA), where users are effectively stuck on an old version.

Root cause

vite-plugin-pwa always registers an SPA NavigationRoute that is bound to the precached index.html, and it registers that route before runtimeCaching. So the precache navigation route shadows the NetworkFirst navigate route that the config intends to use:

// vite.config.ts (workbox)
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api\//],   // only excludes /api/* navigations
runtimeCaching: [
  {
    urlPattern: ({ request }) => request.mode === "navigate",
    handler: "NetworkFirst",
    options: { cacheName: "pages" },
  },
],

navigateFallbackDenylist: [/^\/api\//] only removes /api/* navigations from the precache fallback. Every real app navigation (/, deep links, reloads) still matches the precache NavigationRoute, is answered from the precached index.html, and never reaches the NetworkFirst route. Since the precached HTML references the old hashed asset filenames, the old JS/CSS is served too. The app only moves forward when the SW file itself updates and re-precaches — which is exactly the "reload does nothing" symptom.

Repro

  1. Build + serve the PWA, load it once (SW installs, precaches index.html + hashed assets).
  2. Ship a new build (new hashed asset names, new index.html).
  3. Reload the app (hard reload too), especially as an installed PWA on iOS Safari.
  4. Expected: fresh HTML + new assets. Actual: old HTML/JS keeps being served; the update only lands on some later visit once the SW updates itself.

Proposed fix

Deny every navigation from the precache fallback so navigations fall through to the NetworkFirst runtime route (fresh HTML when online, cached copy when offline):

- navigateFallbackDenylist: [/^\/api\//],
+ navigateFallbackDenylist: [/./],

Tradeoff / discussion

This makes navigations strictly network-first via runtimeCaching instead of being answered by the precached shell. That's the behavior the current config seems to intend (the comment says "Network-first: always try server, fall back to cache"), and offline still works via the NetworkFirst pages cache. The cost is that a cold offline navigation with an empty pages cache no longer falls back to the precached index.html. If keeping a precache offline fallback matters, an alternative is a custom navigationPreload + explicit handler, but the one-line denylist is the minimal fix for the staleness bug.

Happy to send a PR if you're good with the denylist approach (or a variant you prefer).


Found while merging main into a downstream fork; the fork has been running navigateFallbackDenylist: [/./] and it resolved the stale-on-iOS behavior.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions