Vite PWA plugin (vite-plugin-pwa)¶
vite-plugin-pwa is a Vite plugin that turns a Vite build into a progressive web app. It generates and injects the web app manifest, builds a service worker with Workbox's workbox-build (either writing the whole worker for you or injecting a precache manifest into one you wrote), and exposes virtual modules that register the worker and drive a "new version available" prompt in vanilla JS, React, Vue, Svelte, Solid or Preact. It is the de facto PWA layer for the Vite ecosystem, and the same code runs underneath the @vite-pwa/nuxt, @vite-pwa/sveltekit, @vite-pwa/astro and @vite-pwa/vitepress integrations. Knowing its defaults, and exactly which of them it changes behind your back, is the difference between a PWA that updates cleanly and one that serves stale bundles forever.
Key takeaways
- The current release is vite-plugin-pwa 1.3.0 (5 May 2026). It supports Vite 3 through 8, depends on Workbox 7.4.1, and 1.3.0 added the
onNeedReloadcallback to the registration API. - Two decisions matter most:
strategies(generateSWwrites the worker,injectManifestcompiles yours) andregisterType(prompt, the default, orautoUpdate).autoUpdateonly forcesskipWaitingandclientsClaimwheninjectRegisterisauto(the default) ornull. - The plugin changes several
workbox-builddefaults:navigateFallback: "index.html",cleanupOutdatedCaches: true,dontCacheBustURLsMatching: /^assets\//and a source map setting that follows Vite'sbuild.sourcemap. It also precachesmanifest.webmanifest, the manifest icons and everyincludeAssetsmatch with an MD5 revision. - An asset over
maximumFileSizeToCacheInBytes(2 MiB by default) fails the build instead of being silently dropped. This has been the behavior since 0.20.2. virtual:pwa-registerwrapsworkbox-window. The framework hooks default toimmediate: true, while vanillaregisterSW()defaults toimmediate: false. Always import the virtual modules client-side only in SSR or SSG apps.@vite-pwa/assets-generator2.0.0 (September 2026) is ESM-only and needs Node.js 20.19+, but vite-plugin-pwa 1.3.0 still declares a peer range of^1.0.0for it. Stay on 1.x if you use the built-inpwaAssetsintegration.
Versions and compatibility¶
As of September 2026, the npm registry lists these versions for the plugin and its official integrations. Check npm for newer releases before you pin anything.
| Package | Latest | Published | Peer ranges and notes |
|---|---|---|---|
vite-plugin-pwa | 1.3.0 | 2026-05-05 | vite ^3.1.0 through ^8.0.0; depends on workbox-build and workbox-window ^7.4.1; engines.node >= 16 |
@vite-pwa/assets-generator | 2.0.0 | 2026-09-12 | ESM-only, engines.node >= 20.19.0, uses sharp 0.35 |
@vite-pwa/nuxt | 1.1.1 | 2026-02-06 | Nuxt module compatibility >=3.6.5; depends on vite-plugin-pwa ^1.2.0 |
@vite-pwa/sveltekit | 1.1.0 | 2025-11-27 | @sveltejs/kit ^1.3.1 \|\| ^2.0.1 |
@vite-pwa/astro | 1.2.0 | 2025-11-27 | astro ^1.6.0 through ^5.0.0 only (see Astro) |
@vite-pwa/vitepress | 1.1.0 | 2025-11-27 | VitePress integration |
@vite-pwa/remix | 0.2.0 | 2025-03-30 | @remix-run/dev >= 2.8.0 (Remix 2, not React Router 7+) |
@vite-pwa/create-pwa | 1.1.0 | 2025-11-27 | Project scaffolder |
The 1.x line has been stable and incremental. The release notes on GitHub record these changes:
| Version | Date | Change |
|---|---|---|
| 0.20.2 | 2024 | Build throws when a maximumFileSizeToCacheInBytes warning occurs (new showMaximumFileSizeToCacheInBytesWarning escape hatch) |
| 0.21.0 | 2024-11-13 | Workbox updated to 7.3.0 (breaking) |
| 0.21.1 | 2024-11-29 | Vite 6 support; adds a <head> if the entry HTML has none |
| 1.0.0 | 2025-03-29 | Breaking: requires @vite-pwa/assets-generator 1.0.0 |
| 1.0.1 | 2025-06-30 | Vite 7 support |
| 1.0.3 | 2025-08-19 | scope_extensions entries get type: "origin" to match the spec |
| 1.1.0 | 2025-10-13 | Skips service worker generation when the Vite build errored |
| 1.3.0 | 2026-05-05 | Vite 8 peer support; new onNeedReload client callback |
Because the plugin delegates to workbox-build, everything on Workbox fundamentals about generateSW, injectManifest, routes and strategies applies unchanged. This page covers what the plugin adds on top.
What the plugin does during a build¶
vite-plugin-pwa is several Vite plugins that share one context: vite-plugin-pwa (main), vite-plugin-pwa:build, vite-plugin-pwa:dev-sw, vite-plugin-pwa:info and the PWA assets plugin. During vite build the work happens in this order:
flowchart TD
A["configResolved: resolve options, hash includeAssets + manifest"] --> B["transformIndexHtml: inject manifest link + register script"]
B --> C["generateBundle: emit manifest.webmanifest and registerSW.js"]
C --> D["closeBundle: Vite has written dist/"]
D --> E{"strategies"}
E -->|"generateSW"| F["workbox-build generateSW: sw.js + workbox-HASH.js"]
E -->|"injectManifest"| G["Second Vite build compiles srcDir/filename"]
G --> H["workbox-build injectManifest replaces self.__WB_MANIFEST"] Several details follow from this design:
- The worker is generated after the app is written.
workbox-buildis file-based. It globs the finished output directory (globDirectorydefaults to Vite'sbuild.outDir), so the precache manifest always reflects the real hashed filenames. injectManifestruns a second, separate Vite build for your worker source. Plugins from your main Vite config are not applied to that build. You configure them again underinjectManifest.buildPlugins. Onlydefineis shared.- HTML injection only touches HTML that Vite processes. SSR frameworks that render their own
<head>never see the injected tags, which is why the framework integrations and thevirtual:pwa-infomodule exist. - Since 1.1.0 a failed build skips worker generation. A partially written
dist/no longer produces a worker whose precache manifest points at missing files.
Installation and a minimal configuration¶
Install the plugin as a dev dependency. The virtual registration modules import workbox-window from your application's module graph, so under strict package managers such as pnpm, add it as a direct dependency too:
A production-ready starting point for a single-page app looks like this. Every option shown is explained in the reference that follows.
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
VitePWA({
registerType: "prompt", // (1)!
injectRegister: false, // (2)!
includeAssets: ["favicon.ico", "favicon.svg", "apple-touch-icon-180x180.png"],
manifest: {
id: "/", // (3)!
name: "Acme Tasks",
short_name: "Tasks",
description: "Offline-capable task manager",
start_url: "/",
scope: "/",
display: "standalone",
theme_color: "#1e3a8a",
background_color: "#ffffff",
icons: [
{ src: "pwa-64x64.png", sizes: "64x64", type: "image/png" },
{ src: "pwa-192x192.png", sizes: "192x192", type: "image/png" },
{ src: "pwa-512x512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "maskable-icon-512x512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2}"],
navigateFallbackDenylist: [/^\/api\//], // (4)!
},
}),
],
});
promptis the default. It is spelled out here because changing it after release is painful. See registerType.- Registration happens through
virtual:pwa-registerin application code, so no script tag is injected.autowould reach the same result once the virtual module is imported. - The plugin does not default
id. Set it explicitly so the app's identity never depends onstart_url. See app identity. - The plugin sets
navigateFallback: "index.html"for you. Without a denylist, navigations to server-rendered routes such as/api/exportwould get the SPA shell instead.
If you use TypeScript, reference the client types once so the virtual: imports resolve:
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
vite-plugin-pwa/client declares every virtual module. In a monorepo that mixes frameworks, reference only the one you use (vite-plugin-pwa/react, /vue, /svelte, /solid, /preact or /vanillajs), plus vite-plugin-pwa/info and vite-plugin-pwa/pwa-assets if you import those modules.
Plugin option reference¶
The table lists every top-level option in VitePWAOptions (from src/types.ts and the option resolver in 1.3.0), with the default the resolver applies.
| Option | Default | What it controls |
|---|---|---|
strategies | "generateSW" | generateSW writes the worker from config; injectManifest compiles your worker and injects the manifest |
registerType | "prompt" | Update behavior of the virtual registration modules (prompt or autoUpdate) |
injectRegister | "auto" | How registration code gets into the page: "auto", "inline", "script", "script-defer", false (null is deprecated) |
manifest | Defaults object | Web app manifest members, or false to not generate one |
manifestFilename | "manifest.webmanifest" | Output file name of the manifest |
useCredentials | false | Adds crossorigin="use-credentials" to <link rel="manifest"> |
filename | "sw.js" | Worker output name; with injectManifest, also the source file name inside srcDir |
srcDir | "public" | Directory of the injectManifest source worker |
outDir | Vite build.outDir ("dist") | Where the worker is written |
scope | Vite base | Registration scope used by the generated registration code |
base | Vite base | Overrides Vite's base for PWA files only |
buildBase | Resolved base | Public path of the worker, registerSW.js and manifest when the build folder differs from the web root (for example, Laravel's public/build) |
includeAssets | undefined | Globs, relative to Vite's publicDir, added to the precache with an MD5 revision |
includeManifestIcons | true | Precache manifest icons (and shortcut icons) found in publicDir |
minify | true | Minify the generated manifest.webmanifest |
mode | process.env.NODE_ENV or "production" | workbox-build mode for generateSW (ignored by injectManifest since 0.18.0) |
workbox | See below | generateSW options passed to workbox-build |
injectManifest | See below | injectManifest options plus the worker build settings |
devOptions | { enabled: false, type: "classic", suppressWarnings: false } | Service worker in the dev server |
selfDestroying | false | Emit a worker that unregisters itself and deletes all caches |
disable | false | Skip manifest and worker generation entirely |
pwaAssets | Disabled | Experimental on-the-fly icon generation with @vite-pwa/assets-generator |
showMaximumFileSizeToCacheInBytesWarning | false | true restores the old warn-instead-of-throw behavior for oversized assets |
integration | {} | Hooks for framework integrations (beforeBuildServiceWorker, configureOptions, closeBundleOrder, configureCustomSWViteBuild); not for application code |
registerType: prompt or autoUpdate¶
registerType decides what happens when the browser installs a new version of your worker. The mechanics are the standard service worker update lifecycle; the plugin only chooses between two of the patterns described there.
prompt (default). The new worker installs and then sits in the waiting state. generateSW keeps skipWaiting: false, so the generated worker contains the message listener that workbox-build emits whenever skipWaiting is off:
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
The virtual module's onNeedRefresh callback fires, and you show a prompt. When the user accepts, updateServiceWorker() calls workbox-window's messageSkipWaiting(), which posts {type: "SKIP_WAITING"} to the waiting worker. When that worker takes control, the controlling event fires with isUpdate: true, and the module reloads the page (or calls your onNeedReload callback instead, from 1.3.0).
sequenceDiagram
participant Page
participant WB as workbox-window
participant New as New worker
New->>New: install (precache changed files)
WB-->>Page: waiting event, onNeedRefresh()
Page->>Page: show "Update available"
Page->>WB: updateServiceWorker()
WB->>New: postMessage SKIP_WAITING
New->>New: skipWaiting(), activate, cleanupOutdatedCaches
WB-->>Page: controlling (isUpdate: true)
Page->>Page: location.reload() or onNeedReload() autoUpdate. The new worker activates at once and takes over open tabs. The resolver sets workbox.skipWaiting = true and workbox.clientsClaim = true, but only when injectRegister is "auto" or null. With "script", "inline" or false you must set both yourself in workbox. With injectManifest you must call self.skipWaiting() and clientsClaim() in your own worker.
"Automatic reload" also needs the virtual module. The reload logic lives in registerSW(): on the activated event with isUpdate or isExternal, it reloads the page (or calls onNeedReload). If nothing imports a virtual module, the plain registerSW.js script registers the worker and does nothing else. The new worker then controls pages that still run the old JavaScript until the user navigates. For automatic reloads, add this to your entry point:
Choose the update model before the first release
Moving an installed base from autoUpdate to prompt is hard: the old worker keeps calling skipWaiting() on every update until it has been replaced, and old tabs have no prompt UI. autoUpdate also reloads tabs where users may be filling in a form. Pick prompt unless your app is effectively stateless, and read Service worker update patterns compared.
injectRegister: how registration reaches the page¶
injectRegister controls whether the plugin adds registration code to index.html. The generated tags are inserted just before </head> together with the manifest link.
| Value | Injected into index.html | Notes |
|---|---|---|
"auto" (default) | Nothing if any virtual:pwa-register* module is imported; otherwise behaves like "script" | The only mode where autoUpdate forces skipWaiting and clientsClaim |
"script" | <script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script> | registerSW.js registers on window load |
"script-defer" | Same, with defer | Available since 0.17.2 |
"inline" | <script id="vite-plugin-pwa:inline-sw">…</script> with the same code inlined | Saves a request; requires a CSP that allows the inline script (hash it) |
false | Nothing | You register the worker yourself or through a virtual module; null is the deprecated spelling |
The script and inline variants are deliberately minimal:
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" });
});
}
They never show a prompt, never reload, and never check for updates beyond the browser's own checks on navigation. Use them only for autoUpdate sites that accept "new version on next navigation" semantics, or for sites without update UI. If your Content Security Policy forbids inline scripts, avoid "inline" or add the script's hash.
strategies: generateSW or injectManifest¶
generateSW | injectManifest | |
|---|---|---|
| Who writes the worker | workbox-build renders a template | You, in srcDir/filename (JS or TS) |
| Configured through | workbox | injectManifest, plus your source |
Custom push, notificationclick, sync or message handlers | Only through importScripts (workbox.importScripts) | Yes, write them directly |
| Output | sw.js plus a workbox-<hash>.js runtime chunk (inlineWorkboxRuntime: false) | One bundled sw.js (.ts source becomes .js) |
| Bundling | By workbox-build (Rollup) | A separate Vite build with its own plugins |
registerType: "autoUpdate" | skipWaiting/clientsClaim are set for you | You add self.skipWaiting() and clientsClaim() |
registerType: "prompt" | SKIP_WAITING listener is generated | You add the message listener |
Switch to injectManifest as soon as you need any code the config cannot express: push notifications, background sync with custom replay, navigation preload with custom handling, or messages from the page.
manifest: what the plugin generates¶
When manifest is not false, the resolver merges your object over these defaults:
| Member | Default |
|---|---|
name, short_name | name from package.json |
description | description from package.json |
start_url | Resolved base (/) |
scope | The scope option (resolved base) |
display | "standalone" |
background_color | "#ffffff" |
theme_color | "#42b883" |
lang | "en" |
Everything else is copied verbatim, including advanced members such as share_target, file_handlers, protocol_handlers, launch_handler, display_override, edge_side_panel and scope_extensions, which the TypeScript types cover. Two normalizations happen. An icon purpose given as an array (["any", "maskable"]) is joined into the space-separated string the spec requires, both for icons and for each shortcut's icons. Each scope_extensions entry is rewritten to { origin, type }, with type defaulting to "origin".
The file is written as manifestFilename (JSON minified unless minify: false) and linked with <link rel="manifest" href="/manifest.webmanifest">. Its MD5 hash becomes a precache entry, so a manifest change also produces a new worker, which is what you want. Browsers fetch the manifest without credentials. If your manifest sits behind authentication, set useCredentials: true.
Setting manifest: false stops generation, linking and precaching. Use it when you ship a hand-written public/manifest.webmanifest. You then add the <link> yourself and add the file to includeAssets if you want it precached. Add "$schema": "https://json.schemastore.org/web-manifest-combined.json" to a hand-written manifest to get editor validation.
includeAssets and includeManifestIcons¶
workbox-build only precaches files that match globPatterns in the output directory. Files that Vite copies unchanged from public/, such as favicons, are in dist/ too, but the default glob **/*.{js,wasm,css,html} does not match them. The plugin adds a second mechanism that works independently of the glob:
- It collects
includeAssetsglobs and, whenincludeManifestIconsistrue, everysrcinmanifest.iconsandmanifest.shortcuts[].icons(a leading/is stripped). - It resolves them with
tinyglobbyagainst Vite'spublicDir(notdist/). - For each match it computes an MD5 digest of the file contents and adds
{ url, revision }toadditionalManifestEntries, skipping URLs you already listed there yourself. - It adds the manifest itself with its own MD5 revision.
Those entries are therefore precached even if your globPatterns would not match them. An asset matched by both mechanisms does not appear twice in the output. Workbox de-duplicates by URL, and it throws a build error if the same URL arrives with two different revisions. Assets referenced only from src/assets/ must be imported by your code (so Vite emits them with a hash) and matched by globPatterns. An icon that Vite inlines as a data URL never appears in the output at all.
workbox: the generateSW options¶
workbox accepts every generateSW option of workbox-build 7.4.1. The plugin pre-populates some of them, and your values override its values key by key:
| Option | workbox-build default | vite-plugin-pwa default |
|---|---|---|
swDest | (required) | outDir/filename |
globDirectory | (required) | Vite build.outDir |
globPatterns | ["**/*.{js,wasm,css,html}"] | Unchanged |
navigateFallback | null | "index.html" |
cleanupOutdatedCaches | false | true |
dontCacheBustURLsMatching | none | /^assets\// (derived from Vite build.assetsDir) |
sourcemap | true | Follows Vite build.sourcemap (true, "inline" or "hidden" enable it) |
offlineGoogleAnalytics | false | false |
skipWaiting, clientsClaim | false | true with autoUpdate and injectRegister auto/null |
maximumFileSizeToCacheInBytes | 2097152 (2 MiB) | Unchanged, but exceeding it fails the build |
mode | "production" | The plugin's mode option |
dontCacheBustURLsMatching tells Workbox that files under assets/ already carry a content hash in their name, so they are precached without a __WB_REVISION__ query parameter and without a separate revision. Rollup 4 changed its hash alphabet, which broke the older /[.-][a-f0-9]{8}\./ pattern. Since 0.17.0 the plugin derives the pattern from build.assetsDir instead. If you change assetsDir, the pattern follows automatically.
Setting globPatterns replaces the default list entirely. Always keep js, css and html in it, or the worker fails at runtime with WorkboxError non-precached-url index.html, because navigateFallback points at a URL that is not in the precache.
A realistic single-page app configuration with runtime caching:
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,webp,woff2}"],
globIgnores: ["**/stats.html", "**/*.map"],
navigateFallback: "index.html",
navigateFallbackDenylist: [/^\/api\//, /^\/auth\//, /\/[^/?]+\.[^/]+$/], // (1)!
navigationPreload: false,
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
runtimeCaching: [
{
urlPattern: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/"),
handler: "NetworkFirst",
method: "GET",
options: {
cacheName: "api",
networkTimeoutSeconds: 4,
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: /^https:\/\/images\.example-cdn\.com\//,
handler: "CacheFirst",
options: {
cacheName: "cdn-images",
expiration: { maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
- The last pattern excludes navigations to URLs with a file extension (for example, a direct link to
/report.pdf), which should reach the network instead of the app shell.
For how each handler behaves (CacheFirst stores only 200 responses unless you add cacheableResponse, and so on), see Workbox strategies and caching strategies. A multi-page or server-rendered app should set navigateFallback: null. Otherwise every uncached navigation is answered with index.html.
injectManifest: building your own worker¶
With strategies: "injectManifest", the worker source lives at srcDir/filename (by default public/sw.js). A TypeScript source works too: if filename ends in .ts and the file exists, the output is renamed to .js, and the generated registration code uses the .js name. The injectManifest option takes all workbox-build injectManifest options (globPatterns, globIgnores, maximumFileSizeToCacheInBytes, manifestTransforms, additionalManifestEntries, …) plus these build settings:
| Option | Default | Purpose |
|---|---|---|
injectionPoint | "self.__WB_MANIFEST" | Placeholder that gets replaced; set to undefined if your worker does no precaching, or the build fails with "Unable to find a place to inject the manifest" |
rollupFormat | "es" | Output format of the worker bundle ("es" or "iife") |
target | Vite build.target | Syntax target of the worker bundle |
minify | Vite build.minify | Minify the worker |
sourcemap | Vite build.sourcemap | Source map for the worker only |
enableWorkboxModulesLogs | Unset | true defines process.env.NODE_ENV as "development" so Workbox logs survive in production builds |
buildPlugins | Unset | { vite: [...], rollup: [...] }: plugins for the worker build (replaces the deprecated plugins and vitePlugins) |
rollupOptions | {} | Extra Rollup options except plugins and output |
envOptions | Vite envDir, envPrefix | Environment-variable loading for the worker build |
Inside the worker, import.meta.env.MODE, import.meta.env.DEV and import.meta.env.PROD work because the worker is compiled by Vite. Since 0.18.0 the worker build uses the application's mode. workbox-* imports are bundled, so install workbox-precaching, workbox-routing, workbox-strategies and friends as dev dependencies, at the same 7.4.1 version the plugin uses. A complete worker appears later on this page.
devOptions: the service worker in vite dev¶
By default nothing PWA-related runs in the dev server. With devOptions.enabled: true the plugin serves the manifest, and it builds and registers a development worker at /dev-sw.js?dev-sw.
| Option | Default | Applies to | Purpose |
|---|---|---|---|
enabled | false | Both | Turn the dev worker on (only for vite serve) |
type | "classic" | injectManifest | Registration type. Set "module" if your source uses import statements; generateSW always forces "classic" |
navigateFallback | "index.html" | injectManifest | The single URL injected into self.__WB_MANIFEST in dev |
navigateFallbackAllowlist | [/^\/$/] | generateSW | Restricts the dev navigation route to the entry point so other routes reach Vite |
suppressWarnings | false | generateSW | Writes an empty suppress-warnings.js into the dev folder and sets globPatterns to ["suppress-warnings.js"] (the type comment says *.js, but 1.3.0's code uses the file name), which silences workbox-build's "no files matched" warnings |
webManifestUrl | ${base}${manifestFilename} | Both | Deprecated since 0.12.4; still read when building the dev manifest link, but leave it unset |
disableRuntimeConfig | false | generateSW | Drop workbox.runtimeCaching in dev |
resolveTempFolder | <root>/dev-dist | generateSW | Where the dev worker is written (for integrations) |
Things to know before you rely on it:
- The dev worker precaches only the navigation fallback. Vite serves unbundled modules on demand, so there is nothing meaningful to precache. Offline testing in dev tells you nothing about the production precache. Use
vite build && vite previewfor that. generateSWwrites the dev worker todev-dist/. Add that folder to.gitignore.- Module workers (
type: "module") are supported in Chromium, in Safari since 15 and in Firefox since 147 (see registration and scope). Production builds always register withtype: "classic". - Hot module replacement does not work inside a service worker. Changing the worker source triggers a full page reload.
workbox-windowclassifies each installing worker with a heuristic: it is external (not the result of this page'sregister()call) when it appears more than 60 seconds after registration, when it is the secondupdatefoundin the page's lifetime, or when its script URL differs.isUpdateistrueonly if the page already had a controller when it loaded. After a hard reload (which bypasses the controller) or during rapid rebuilds, those flags can make "offline ready" appear where you expect "update available", or the reverse. That is an artifact of the heuristic, not a bug in your code. - If you register the worker yourself with
injectRegister: falseandinjectManifest, use the dev URL in development:
if ("serviceWorker" in navigator) {
const dev = import.meta.env.DEV;
navigator.serviceWorker.register(dev ? "/dev-sw.js?dev-sw" : "/sw.js", {
scope: "/",
type: dev ? "module" : "classic", // module only if your source uses import statements
});
}
The virtual modules¶
The plugin resolves several virtual: imports. They exist only inside a Vite build, so they cannot be imported from Node scripts or tests that don't run through Vite.
| Module | Exports | Types reference |
|---|---|---|
virtual:pwa-register | registerSW(options) | vite-plugin-pwa/client or /vanillajs |
virtual:pwa-register/react | useRegisterSW(options) returning useState tuples | vite-plugin-pwa/react |
virtual:pwa-register/preact | Same shape as React | vite-plugin-pwa/preact |
virtual:pwa-register/vue | useRegisterSW(options) returning Ref<boolean>s | vite-plugin-pwa/vue |
virtual:pwa-register/svelte | useRegisterSW(options) returning Writable<boolean> stores | vite-plugin-pwa/svelte |
virtual:pwa-register/solid | useRegisterSW(options) returning [Accessor, Setter] pairs | vite-plugin-pwa/solid |
virtual:pwa-info | pwaInfo (manifest link data, registration data) | vite-plugin-pwa/info |
virtual:pwa-assets/head, virtual:pwa-assets/icons | Generated icon links and manifest icons | vite-plugin-pwa/pwa-assets (meant for integrations) |
RegisterSWOptions¶
Every registration module takes the same options object:
| Option | Signature | When it fires |
|---|---|---|
immediate | boolean | Register right away instead of waiting for window load. Default false for registerSW(), true for every framework useRegisterSW() |
onNeedRefresh | () => void | prompt mode: a new worker is waiting (also when it was already waiting before this page loaded) |
onOfflineReady | () => void | The first worker finished installing: the app works offline |
onNeedReload | () => void | (1.3.0+) The new worker took control and the module would reload the page; if provided, it replaces location.reload() |
onRegisteredSW | (swUrl: string, reg: ServiceWorkerRegistration \| undefined) => void | After register() resolves (0.12.8+) |
onRegistered | (reg: ServiceWorkerRegistration \| undefined) => void | Deprecated; called only if onRegisteredSW is absent |
onRegisterError | (error: any) => void | Loading workbox-window or registering failed |
registerSW() returns updateServiceWorker(reloadPage?: boolean): Promise<void>. The reloadPage argument has been ignored since 0.13.2: in prompt mode the function sends SKIP_WAITING and the reload happens on controlling. In autoUpdate mode the function does nothing.
How registerSW() works internally¶
The implementation in 1.3.0 is short enough to reason about completely:
- If
'serviceWorker' in navigatoris false, nothing happens, and no callback fires. - It dynamically imports
workbox-windowand constructsnew Workbox(swUrl, { scope, type })with values baked in at build time. A failed import callsonRegisterError. autoUpdate: onactivatedwithevent.isUpdate || event.isExternal, it callsonNeedReload()orwindow.location.reload(). Oninstalledwith!event.isUpdate, it callsonOfflineReady().prompt: onwaiting(and on aninstalledevent flaggedisExternal), it registers a one-timecontrollinglistener that reloads (or callsonNeedReload) whenisUpdateis true, then callsonNeedRefresh(). On the first install it callsonOfflineReady().- It calls
wb.register({ immediate }), thenonRegisteredSW(swUrl, registration).
With selfDestroying: true none of the event handlers are attached, so no prompts appear while the worker removes itself.
onNeedReload fixes a real UX problem: in autoUpdate mode a page reload can arrive in the middle of user input. With the callback you can defer the reload to the next client-side navigation:
import { registerSW } from "virtual:pwa-register";
let reloadPending = false;
registerSW({
immediate: true,
onNeedReload() {
// New worker is in control; old JS is still running. Reload at a safe point.
reloadPending = true;
},
});
// Call this from your router's "after navigation" hook.
export function reloadIfUpdated(nextUrl: string): boolean {
if (!reloadPending) return false;
window.location.assign(nextUrl); // full navigation: loads the new build
return true;
}
virtual:pwa-info for SSR and custom HTML¶
When a framework renders HTML itself, the plugin cannot inject the manifest link. virtual:pwa-info exposes what it would have injected:
interface PwaInfo {
pwaInDevEnvironment: boolean;
webManifest: { href: string; useCredentials: boolean; linkTag: string };
registerSW?: {
mode: "inline" | "script" | "script-defer";
inlinePath: string;
registerPath: string;
scope: string;
type: "classic" | "module";
scriptTag?: string;
};
}
pwaInfo is undefined when disable is true, when manifest is false, and in the dev server unless devOptions.enabled is true. registerSW is present only when the plugin would inject a script: with "script", "script-defer" or "inline", or with "auto" when no virtual registration module is imported. Render pwaInfo.webManifest.linkTag into your <head>. The SvelteKit and Astro examples below do exactly that.
A complete injectManifest worker¶
The worker below covers precaching, SPA navigation fallback with a denylist, runtime caching, the prompt update handshake and a push handler. It compiles with strategies: "injectManifest", srcDir: "src" and filename: "sw.ts".
/// <reference lib="webworker" />
import { clientsClaim } from "workbox-core";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import {
cleanupOutdatedCaches,
createHandlerBoundToURL,
precacheAndRoute,
} from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
declare let self: ServiceWorkerGlobalScope;
// 1. Precache: the plugin replaces self.__WB_MANIFEST with [{url, revision}, ...].
precacheAndRoute(self.__WB_MANIFEST);
// Delete precaches created by older Workbox versions / previous builds.
cleanupOutdatedCaches();
// 2. SPA navigation fallback. In dev only "/" is allowed, so Vite can serve routes.
const allowlist = import.meta.env.DEV ? [/^\/$/] : undefined;
registerRoute(
new NavigationRoute(createHandlerBoundToURL("index.html"), {
allowlist,
denylist: [/^\/api\//, /^\/auth\//],
}),
);
// 3. Runtime caching for same-origin GET API calls.
registerRoute(
({ url, request, sameOrigin }) =>
sameOrigin && request.method === "GET" && url.pathname.startsWith("/api/"),
new NetworkFirst({
cacheName: "api",
networkTimeoutSeconds: 4,
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 }),
],
}),
);
registerRoute(
({ request }) => request.destination === "image",
new StaleWhileRevalidate({
cacheName: "images",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 200, purgeOnQuotaError: true }),
],
}),
);
// 4. Update handshake for registerType: "prompt".
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
// For registerType: "autoUpdate" use these two lines instead of the listener above:
// self.skipWaiting();
// clientsClaim();
void clientsClaim; // keep the import when using prompt mode
// 5. Push: always show a notification (userVisibleOnly contract).
self.addEventListener("push", (event) => {
const data = (() => {
try {
return event.data?.json() ?? {};
} catch {
return { title: "Update", body: event.data?.text() ?? "" };
}
})();
event.waitUntil(
self.registration.showNotification(data.title ?? "Acme Tasks", {
body: data.body,
icon: "/pwa-192x192.png",
data: { url: data.url ?? "/" },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const target = new URL(event.notification.data?.url ?? "/", self.location.origin).href;
event.waitUntil(
(async () => {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const existing = windows.find((c) => c.url === target);
if (existing) return existing.focus();
return self.clients.openWindow(target);
})(),
);
});
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
VitePWA({
strategies: "injectManifest",
srcDir: "src",
filename: "sw.ts",
registerType: "prompt",
injectRegister: false,
manifest: { /* … as above … */ },
injectManifest: {
globPatterns: ["**/*.{js,css,html,svg,png,woff2}"],
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
minify: true,
sourcemap: "hidden",
},
devOptions: { enabled: true, type: "module", navigateFallback: "index.html" },
}),
],
});
Add "WebWorker" to compilerOptions.lib in the tsconfig.json that covers src/sw.ts, or better, give the worker its own tsconfig so DOM and WebWorker globals don't mix. The push part of the worker is covered in depth on push notifications and Notifications API.
Update prompts in each framework¶
The components below implement registerType: "prompt" with accessible markup and a periodic update check. All of them work with both strategies (with injectManifest, your worker needs the SKIP_WAITING listener shown above).
A robust periodic update check¶
The browser checks for a new worker on navigations and functional events, but a single-page app that stays open for days rarely navigates. The helper below checks hourly. It skips the check while offline and while a worker is already installing, and first probes the worker URL with cache: "no-store" so a server outage does not produce a failed update().
const HOUR = 60 * 60 * 1000;
export function schedulePeriodicUpdate(
swUrl: string,
registration: ServiceWorkerRegistration | undefined,
intervalMs = HOUR,
): () => void {
if (!registration) return () => {};
const check = async () => {
if (registration.installing || !navigator.onLine) return;
try {
const resp = await fetch(swUrl, {
cache: "no-store",
headers: { "cache-control": "no-cache" },
});
if (resp.status === 200) await registration.update();
} catch {
// Network error: try again at the next tick.
}
};
const id = window.setInterval(check, intervalMs);
// Also check when a long-hidden tab becomes visible again.
const onVisible = () => document.visibilityState === "visible" && check();
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearInterval(id);
document.removeEventListener("visibilitychange", onVisible);
};
}
The components¶
import { registerSW } from "virtual:pwa-register";
import { schedulePeriodicUpdate } from "./periodic-update";
const toast = document.querySelector<HTMLDivElement>("#pwa-toast")!;
const message = toast.querySelector<HTMLSpanElement>(".message")!;
const reloadBtn = toast.querySelector<HTMLButtonElement>("#pwa-reload")!;
const closeBtn = toast.querySelector<HTMLButtonElement>("#pwa-close")!;
function show(text: string, canReload: boolean) {
message.textContent = text;
reloadBtn.hidden = !canReload;
toast.hidden = false;
}
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
show("A new version is available.", true);
},
onOfflineReady() {
show("Ready to work offline.", false);
},
onRegisteredSW(swUrl, registration) {
schedulePeriodicUpdate(swUrl, registration);
},
onRegisterError(error) {
console.error("Service worker registration failed", error);
},
});
reloadBtn.addEventListener("click", () => {
reloadBtn.disabled = true; // avoid double SKIP_WAITING
void updateSW(); // page reloads on "controlling"
});
closeBtn.addEventListener("click", () => {
toast.hidden = true;
});
import { useRegisterSW } from "virtual:pwa-register/react";
import { schedulePeriodicUpdate } from "./periodic-update";
export function ReloadPrompt() {
const {
needRefresh: [needRefresh, setNeedRefresh],
offlineReady: [offlineReady, setOfflineReady],
updateServiceWorker,
} = useRegisterSW({
// immediate defaults to true in the React hook
onRegisteredSW(swUrl, registration) {
schedulePeriodicUpdate(swUrl, registration);
},
onRegisterError(error) {
console.error("Service worker registration failed", error);
},
});
if (!needRefresh && !offlineReady) return null;
const close = () => {
setNeedRefresh(false);
setOfflineReady(false);
};
return (
<div className="pwa-toast" role="status" aria-live="polite">
<span>{needRefresh ? "A new version is available." : "Ready to work offline."}</span>
{needRefresh && (
<button type="button" onClick={() => void updateServiceWorker()}>
Reload
</button>
)}
<button type="button" onClick={close}>
Close
</button>
</div>
);
}
The hook registers once per component instance (the registerSW call is inside a useState initializer). Mount <ReloadPrompt /> exactly once, near the root, and never inside a route component that remounts.
<script setup lang="ts">
import { useRegisterSW } from "virtual:pwa-register/vue";
import { schedulePeriodicUpdate } from "./periodic-update";
const { needRefresh, offlineReady, updateServiceWorker } = useRegisterSW({
onRegisteredSW(swUrl, registration) {
schedulePeriodicUpdate(swUrl, registration);
},
onRegisterError(error) {
console.error("Service worker registration failed", error);
},
});
function close() {
needRefresh.value = false;
offlineReady.value = false;
}
</script>
<template>
<div v-if="needRefresh || offlineReady" class="pwa-toast" role="status" aria-live="polite">
<span>{{ needRefresh ? "A new version is available." : "Ready to work offline." }}</span>
<button v-if="needRefresh" type="button" @click="updateServiceWorker()">Reload</button>
<button type="button" @click="close">Close</button>
</div>
</template>
<script lang="ts">
import { useRegisterSW } from "virtual:pwa-register/svelte";
import { schedulePeriodicUpdate } from "./periodic-update";
// Returns svelte/store writables; the $store syntax works in runes mode.
const { needRefresh, offlineReady, updateServiceWorker } = useRegisterSW({
onRegisteredSW(swUrl, registration) {
schedulePeriodicUpdate(swUrl, registration);
},
onRegisterError(error) {
console.error("Service worker registration failed", error);
},
});
function close() {
needRefresh.set(false);
offlineReady.set(false);
}
</script>
{#if $needRefresh || $offlineReady}
<div class="pwa-toast" role="status" aria-live="polite">
<span>{$needRefresh ? "A new version is available." : "Ready to work offline."}</span>
{#if $needRefresh}
<button type="button" onclick={() => updateServiceWorker()}>Reload</button>
{/if}
<button type="button" onclick={close}>Close</button>
</div>
{/if}
In SSR or SSG setups (Nuxt, SvelteKit, Astro, VitePress, vite-ssg), these modules must only run in the browser. Load the component dynamically on the client, or register inside an onMount/onMounted hook. Otherwise the server build fails with navigator is not defined or window is not defined. Designing the prompt itself (wording, placement, not interrupting input) is covered in offline UX and app-like UX.
@vite-pwa/assets-generator¶
@vite-pwa/assets-generator produces every icon a PWA needs from one source image, using sharp and sharp-ico. It works as a CLI, as a library, and as the engine behind the plugin's experimental pwaAssets option.
CLI usage¶
npm install -D @vite-pwa/assets-generator
npx pwa-assets-generator --preset minimal-2023 public/logo.svg
| Flag | Meaning |
|---|---|
-r, --root <path> | Project root (default process.cwd()); sources are relative to it |
-c, --config <path> | Path to a config file |
-p, --preset <name> | minimal-2023, minimal (deprecated), android, windows, ios or all |
-o, --override | Overwrite existing files (default true; --override=false to disable) |
-m, --manifest | Print the manifest icons entry (default true) |
--html.basePath, --html.preset, --html.xhtml, --html.includeId | Control the printed <link> tags |
Always pass a preset or use a config file that sets one. Without a preset, the CLI stops with "No preset found". Output goes next to the source image. The android, windows, ios and all presets are marked work-in-progress in the documentation. minimal-2023 is the one to use.
What minimal-2023 generates¶
| File | Size | Purpose | Padding |
|---|---|---|---|
favicon.ico | 48×48 | <link rel="icon" href="/favicon.ico" sizes="48x48"> | 0.05 |
pwa-64x64.png | 64×64 | Manifest icon (small displays, Windows) | 0.05 |
pwa-192x192.png | 192×192 | Manifest icon (installability minimum) | 0.05 |
pwa-512x512.png | 512×512 | Manifest icon, purpose: "any" | 0.05 |
maskable-icon-512x512.png | 512×512 | Manifest icon, purpose: "maskable", on a white background | 0.3 |
apple-touch-icon-180x180.png | 180×180 | <link rel="apple-touch-icon">, on a white background | 0.3 |
The SVG source itself becomes <link rel="icon" href="/logo.svg" sizes="any" type="image/svg+xml">. PNGs are compressed with compressionLevel: 9, quality: 60 unless you pass png options. The maskable icon's 0.3 padding keeps the logo inside the maskable safe zone. Don't reuse one file for both any and maskable.
Config file, custom presets and Apple splash screens¶
A pwa-assets.config.ts (or .js, .mjs, .cjs, .cts, .mts) in the project root lets you change padding, background colors, file names and add Apple launch images:
import {
combinePresetAndAppleSplashScreens,
defineConfig,
minimal2023Preset,
} from "@vite-pwa/assets-generator/config";
export default defineConfig({
headLinkOptions: { preset: "2023" },
preset: combinePresetAndAppleSplashScreens(
{
...minimal2023Preset,
maskable: { ...minimal2023Preset.maskable, resizeOptions: { background: "#1e3a8a" } },
apple: { ...minimal2023Preset.apple, resizeOptions: { background: "#1e3a8a" } },
},
{
padding: 0.3,
resizeOptions: { background: "#ffffff", fit: "contain" },
darkResizeOptions: { background: "#0b1020", fit: "contain" }, // also emit dark variants
linkMediaOptions: { log: true, addMediaScreen: true, basePath: "/", xhtml: false },
png: { compressionLevel: 9, quality: 60 },
},
["iPhone 16 Pro", "iPhone 16 Pro Max", 'iPad Pro 12.9"'], // (1)!
),
images: ["public/logo.svg"],
});
- Device names come from the
AppleDeviceNametype exported by@vite-pwa/assets-generator/config(AllAppleDeviceNameslists them all). Releases 1.0.1 and 1.0.4 added newer iPhone and iPad models. Check the installed version's type definitions for the exact names, and omit the array to generate every device.
With linkMediaOptions.log: true, the CLI prints the <link rel="apple-touch-startup-image" media="…"> tags you must add to your HTML. Each one is keyed by device width, height, pixel ratio and orientation. Whether you still want launch images at all is discussed in splash screens.
The pwaAssets integration (experimental)¶
Since 0.19.0 the plugin can run the generator itself, serving icons from memory in dev and emitting them at build time:
VitePWA({
pwaAssets: {
// Uses pwa-assets.config.* if present (config: true), else these inline options:
image: "public/logo.svg", // default: public/favicon.svg
preset: "minimal-2023", // default
htmlPreset: "2023", // default
includeHtmlHeadLinks: true, // inject favicon / apple-touch-icon links
overrideManifestIcons: false, // true replaces manifest.icons with generated ones
injectThemeColor: true, // <meta name="theme-color"> from manifest.theme_color
},
manifest: { name: "Acme Tasks", short_name: "Tasks", theme_color: "#1e3a8a" },
});
If manifest.icons is missing, the generated icons are added automatically. The integration supports only one source image. An external config file is watched without restarting the dev server, while inline options restart it when they change. The option is marked experimental and may change without a major version.
assets-generator 2.0 and the plugin's peer range
@vite-pwa/assets-generator 2.0.0 (12 September 2026) switched to ESM-only and requires Node.js 20.19 or later. vite-plugin-pwa 1.3.0 and the 1.x framework integrations still declare "@vite-pwa/assets-generator": "^1.0.0" as a peer dependency. Running the CLI on its own with 2.0 is fine. If you use pwaAssets, install @vite-pwa/assets-generator@^1 (1.0.4 was released the same day with the newest Apple devices and the sharp security update) until the plugin widens its range.
Meta-framework integrations¶
The integrations wrap vite-plugin-pwa and fix the parts that the generic plugin cannot know: the framework's output directory, its asset directory for dontCacheBustURLsMatching, how HTML is rendered and where a fallback page lives. The configuration surface is always the plugin's own options plus a few framework keys. Full setup, including the frameworks' own service worker APIs, is on Framework integrations. The essentials follow.
Nuxt¶
export default defineNuxtConfig({
modules: ["@vite-pwa/nuxt"], // install: npx nuxi@latest module add @vite-pwa/nuxt
pwa: {
registerType: "prompt",
manifest: { name: "Acme Tasks", short_name: "Tasks", theme_color: "#1e3a8a" },
workbox: { navigateFallback: "/", globPatterns: ["**/*.{js,css,html,png,svg,ico}"] },
client: { installPrompt: true, periodicSyncForUpdates: 3600 },
devOptions: { enabled: false },
},
});
The module derives dontCacheBustURLsMatching from app.buildAssetsDir (_nuxt). It exposes a $pwa object (needRefresh, offlineReady, updateServiceWorker(), showInstallPrompt, install(), isPWAInstalled, …) and provides the <VitePwaManifest />, <NuxtPwaManifest /> and <NuxtPwaAssets /> components that render the manifest link.
SvelteKit¶
import { sveltekit } from "@sveltejs/kit/vite";
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
sveltekit(),
SvelteKitPWA({
registerType: "prompt",
manifest: { name: "Acme Tasks", short_name: "Tasks", theme_color: "#1e3a8a" },
kit: { includeVersionFile: true },
}),
],
});
The plugin sets globDirectory to .svelte-kit/output and globs client/**/*.{js,css,ico,png,svg,webp,webmanifest} and prerendered/**/*.{html,json}, with server/** in globIgnores. A manifestTransforms step then strips the client/ and prerendered/pages/ prefixes and turns about.html into /about, so precache URLs match what the browser requests. Because the glob root is output/, not output/client/, your own globPatterns must carry the client/ or prerendered/ prefix, or they match nothing. If you supply any client/ pattern, the default one is not added, so list every extension you need. Other SvelteKit-specific defaults: includeManifestIcons is false, dontCacheBustURLsMatching is _app/immutable/, navigateFallback is kit.adapterFallback or the base path, and with strategies: "injectManifest" the source defaults to src/service-worker.js. Render the manifest link from virtual:pwa-info in +layout.svelte. Set kit.serviceWorker.register: false in svelte.config.js when you register through a virtual module.
Astro¶
import { defineConfig } from "astro/config";
import AstroPWA from "@vite-pwa/astro";
export default defineConfig({
integrations: [
AstroPWA({
registerType: "autoUpdate",
manifest: { name: "Acme Docs", short_name: "Docs", theme_color: "#1e3a8a" },
workbox: { navigateFallback: "/404", globPatterns: ["**/*.{css,js,html,svg,png,ico,txt}"] },
}),
],
});
The integration writes to Astro's client output directory, sets includeManifestIcons: false, derives dontCacheBustURLsMatching from build.assets (_astro/), defaults navigateFallback to the site's base, and rewrites precached .html entries to the clean URLs Astro serves (about.html becomes about, or about/ with trailingSlash: "always"). That rewrite is why navigateFallback: "/404" resolves to the precached 404.html. Astro components don't ship client JavaScript by default, so you must import a registration module from a <script> in your layout and render pwaInfo.webManifest.linkTag with <Fragment set:html={…} />. @vite-pwa/astro 1.2.0 declares astro peer versions only up to ^5.0.0. Astro 6 and 7 installs need a package-manager override (see Framework integrations).
VitePress and Remix¶
@vite-pwa/vitepress wraps the VitePress config (withPwa(defineConfig({ …, pwa: {…} }))) and derives the asset directory from VitePress' assetsDir. @vite-pwa/remix targets Remix 2 on Vite (@remix-run/dev >= 2.8.0) through a Remix preset plus plugin. It does not support React Router 7 or 8 framework mode.
Removing the service worker: selfDestroying¶
If you need to take a PWA offline, for example to remove the worker completely, set selfDestroying: true and deploy. The plugin then emits a worker with the same file name that, on activation, unregisters itself, deletes every Cache Storage entry (0.17.2+), and navigates open windows so they load without a controller. Keep everything else unchanged, especially filename: the replacement only works if it sits at the URL the browser already has registered. The virtual modules detect the mode and don't show prompts.
To replace a worker with a new one at a different URL instead, keep a kill-switch script at the old URL (see updating service workers) and point filename at the new name. Never delete an old worker file from the server while clients may still have it registered. A 404 on the update check leaves the old worker installed indefinitely.
Serving the output correctly¶
The plugin cannot control your HTTP layer, and most "my PWA never updates" reports come from there:
| File | Required headers | Why |
|---|---|---|
/sw.js | Cache-Control: no-cache (or max-age=0) | The update check honors HTTP caching of the worker only up to 24 hours, but a CDN edge cache can still serve a stale worker for its full TTL |
/workbox-*.js | Long-lived caching is fine (hashed name) | Referenced from sw.js |
/registerSW.js | no-cache | Not hashed |
/manifest.webmanifest | Content-Type: application/manifest+json, no-cache | Precached with a revision; stale copies confuse install UI |
/index.html | no-cache | The precache serves it offline, but the network copy must be current |
/assets/* | Cache-Control: public, max-age=31536000, immutable | Content-hashed; excluded from cache busting |
See HTTP caching and service workers for the reasoning and for CDN configuration.
Common pitfalls¶
- Assuming
autoUpdatereloads tabs by itself. Without a virtual module import, nothing reloads. WithinjectRegister: "script",skipWaitingisn't even set for you. - Overriding
globPatternswithouthtml. The navigation fallback then points at a non-precached URL, and every navigation throwsnon-precached-url. - Keeping
navigateFallback: "index.html"in a multi-page or SSR app. Every uncached navigation returns the SPA shell. SetnavigateFallback: nullor configure a denylist. - Importing a virtual module in server code. SSR builds fail. Import it only from client-only code paths.
- Mixing Workbox versions. With
injectManifest, installworkbox-*at the same version as the plugin'sworkbox-build(7.4.1). Two copies ofworkbox-corein the worker bundle cause subtle failures. - Testing offline in the dev server. The dev worker precaches one URL. Test with
vite build && vite preview. - Oversized assets. A 2.5 MB WASM file fails the build since 0.20.2. Raise
maximumFileSizeToCacheInBytesdeliberately, or exclude the file withglobIgnoresand cache it at runtime instead. - Registering in a component that remounts. Each React
useRegisterSW()instance registers again and attaches its own listeners. Mount one prompt component at the root. - Serving
sw.jswith a longmax-agefrom a CDN. Updates stall until the edge cache expires.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Build error: "Assets exceeding the limit … maximumFileSizeToCacheInBytes" | A file over 2 MiB matched globPatterns | Raise the limit in workbox/injectManifest, or exclude the file and runtime-cache it |
| Build error: "Unable to find a place to inject the manifest" | injectManifest source has no self.__WB_MANIFEST | Add precacheAndRoute(self.__WB_MANIFEST) or set injectManifest.injectionPoint: undefined |
TypeScript error 2307 on virtual:pwa-register | Types not referenced | Add vite-plugin-pwa/client (or the framework-specific entry) to types or a /// <reference> |
WorkboxError: non-precached-url index.html | globPatterns excludes HTML, or the fallback URL doesn't exist in the output | Include html in globPatterns; point navigateFallback at a real file |
| Manifest request returns 401 | App behind auth; manifest fetched without credentials | useCredentials: true |
| "Offline ready" appears where "update available" was expected | The page loaded without a controller (first visit, or a hard reload), so workbox-window reported isUpdate: false; or its external-update heuristic (more than 60 s after registration, or a second update) classified the worker differently | Expected during development and rapid redeploys; handle both callbacks gracefully |
Routes like /admin show the SPA shell | Navigation fallback catches them | Add them to navigateFallbackDenylist |
| Old UI persists after deploy | sw.js cached by CDN or browser; or no update check in a long-lived tab | Fix headers; add the periodic update check |
Uncaught (in promise) TypeError: … Module scripts don't support importScripts() in dev | generateSW dev worker registered as module | Keep devOptions.type at "classic" for generateSW |
| Worker missing entirely after build | Build errored (since 1.1.0 generation is skipped), or disable: true | Check the build log |
For the browser side of debugging (Application panel, update on reload, bypass for network), see browser DevTools. For end-to-end tests of update flows, see automated testing.
Further reading¶
On this site
- Workbox fundamentals: the
workbox-buildoptions this plugin passes through - Advanced Workbox: custom plugins, bundling and migration to Serwist
- Framework integrations: Nuxt, SvelteKit, Astro, Next.js, Angular and more
- Updating service workers: the lifecycle behind
promptandautoUpdate - Precaching and runtime caching
- Icons and maskable icons
- Service worker update patterns compared
External references