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
- Build + serve the PWA, load it once (SW installs, precaches
index.html + hashed assets).
- Ship a new build (new hashed asset names, new
index.html).
- Reload the app (hard reload too), especially as an installed PWA on iOS Safari.
- 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.
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-pwaalways registers an SPANavigationRoutethat is bound to the precachedindex.html, and it registers that route beforeruntimeCaching. So the precache navigation route shadows theNetworkFirstnavigate route that the config intends to use:navigateFallbackDenylist: [/^\/api\//]only removes/api/*navigations from the precache fallback. Every real app navigation (/, deep links, reloads) still matches the precacheNavigationRoute, is answered from the precachedindex.html, and never reaches theNetworkFirstroute. 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
index.html+ hashed assets).index.html).Proposed fix
Deny every navigation from the precache fallback so navigations fall through to the
NetworkFirstruntime route (fresh HTML when online, cached copy when offline):Tradeoff / discussion
This makes navigations strictly network-first via
runtimeCachinginstead 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 theNetworkFirstpagescache. The cost is that a cold offline navigation with an emptypagescache no longer falls back to the precachedindex.html. If keeping a precache offline fallback matters, an alternative is a customnavigationPreload+ 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
maininto a downstream fork; the fork has been runningnavigateFallbackDenylist: [/./]and it resolved the stale-on-iOS behavior.