Skip to content

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 onNeedReload callback to the registration API.
  • Two decisions matter most: strategies (generateSW writes the worker, injectManifest compiles yours) and registerType (prompt, the default, or autoUpdate). autoUpdate only forces skipWaiting and clientsClaim when injectRegister is auto (the default) or null.
  • The plugin changes several workbox-build defaults: navigateFallback: "index.html", cleanupOutdatedCaches: true, dontCacheBustURLsMatching: /^assets\// and a source map setting that follows Vite's build.sourcemap. It also precaches manifest.webmanifest, the manifest icons and every includeAssets match 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-register wraps workbox-window. The framework hooks default to immediate: true, while vanilla registerSW() defaults to immediate: false. Always import the virtual modules client-side only in SSR or SSG apps.
  • @vite-pwa/assets-generator 2.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.0 for it. Stay on 1.x if you use the built-in pwaAssets integration.

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-build is file-based. It globs the finished output directory (globDirectory defaults to Vite's build.outDir), so the precache manifest always reflects the real hashed filenames.
  • injectManifest runs 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 under injectManifest.buildPlugins. Only define is 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 the virtual:pwa-info module 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:

Terminal
npm install -D vite-plugin-pwa workbox-window
Terminal
pnpm add -D vite-plugin-pwa workbox-window
Terminal
# Templates: vanilla, vue, react, preact, lit, svelte, solid, qwik (each also as -ts);
# the interactive prompt also offers Nuxt, SvelteKit and Remix scaffolds
npm create @vite-pwa/pwa@latest my-app -- --template react-ts

A production-ready starting point for a single-page app looks like this. Every option shown is explained in the reference that follows.

vite.config.ts
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)!
      },
    }),
  ],
});
  1. prompt is the default. It is spelled out here because changing it after release is painful. See registerType.
  2. Registration happens through virtual:pwa-register in application code, so no script tag is injected. auto would reach the same result once the virtual module is imported.
  3. The plugin does not default id. Set it explicitly so the app's identity never depends on start_url. See app identity.
  4. The plugin sets navigateFallback: "index.html" for you. Without a denylist, navigations to server-rendered routes such as /api/export would get the SPA shell instead.

If you use TypeScript, reference the client types once so the virtual: imports resolve:

src/vite-env.d.ts
/// <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:

dist/sw.js (excerpt generated by workbox-build)
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:

src/main.ts
import { registerSW } from "virtual:pwa-register";

registerSW({ immediate: true });

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:

dist/registerSW.js
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:

  1. It collects includeAssets globs and, when includeManifestIcons is true, every src in manifest.icons and manifest.shortcuts[].icons (a leading / is stripped).
  2. It resolves them with tinyglobby against Vite's publicDir (not dist/).
  3. For each match it computes an MD5 digest of the file contents and adds { url, revision } to additionalManifestEntries, skipping URLs you already listed there yourself.
  4. 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:

vite.config.ts (workbox section)
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] },
      },
    },
  ],
},
  1. 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 preview for that.
  • generateSW writes the dev worker to dev-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 with type: "classic".
  • Hot module replacement does not work inside a service worker. Changing the worker source triggers a full page reload. workbox-window classifies each installing worker with a heuristic: it is external (not the result of this page's register() call) when it appears more than 60 seconds after registration, when it is the second updatefound in the page's lifetime, or when its script URL differs. isUpdate is true only 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: false and injectManifest, use the dev URL in development:
src/register-sw.ts
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:

  1. If 'serviceWorker' in navigator is false, nothing happens, and no callback fires.
  2. It dynamically imports workbox-window and constructs new Workbox(swUrl, { scope, type }) with values baked in at build time. A failed import calls onRegisterError.
  3. autoUpdate: on activated with event.isUpdate || event.isExternal, it calls onNeedReload() or window.location.reload(). On installed with !event.isUpdate, it calls onOfflineReady().
  4. prompt: on waiting (and on an installed event flagged isExternal), it registers a one-time controlling listener that reloads (or calls onNeedReload) when isUpdate is true, then calls onNeedRefresh(). On the first install it calls onOfflineReady().
  5. It calls wb.register({ immediate }), then onRegisteredSW(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:

src/pwa.ts
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:

Type of pwaInfo
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".

src/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);
    })(),
  );
});
vite.config.ts
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().

src/pwa/periodic-update.ts
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

src/pwa/prompt.ts
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;
});
index.html (fragment)
<div id="pwa-toast" role="status" aria-live="polite" hidden>
  <span class="message"></span>
  <button id="pwa-reload" type="button">Reload</button>
  <button id="pwa-close" type="button">Close</button>
</div>
src/pwa/ReloadPrompt.tsx
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.

src/pwa/ReloadPrompt.vue
<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>
src/lib/ReloadPrompt.svelte
<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

Terminal
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:

pwa-assets.config.ts
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"],
});
  1. Device names come from the AppleDeviceName type exported by @vite-pwa/assets-generator/config (AllAppleDeviceNames lists 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:

vite.config.ts
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

nuxt.config.ts
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

vite.config.ts
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

astro.config.mjs
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 autoUpdate reloads tabs by itself. Without a virtual module import, nothing reloads. With injectRegister: "script", skipWaiting isn't even set for you.
  • Overriding globPatterns without html. The navigation fallback then points at a non-precached URL, and every navigation throws non-precached-url.
  • Keeping navigateFallback: "index.html" in a multi-page or SSR app. Every uncached navigation returns the SPA shell. Set navigateFallback: null or 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, install workbox-* at the same version as the plugin's workbox-build (7.4.1). Two copies of workbox-core in 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 maximumFileSizeToCacheInBytes deliberately, or exclude the file with globIgnores and 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.js with a long max-age from 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

External references