React PWA Update System
A production approach to React app updates: Vite, service worker, vite-plugin-pwa, build versioning, an update button, and cache reset.
The web has an unpleasant feature: you deploy a new version of your application, while some users keep living inside an old tab.
For a regular page, this is almost invisible. The user follows a link, the browser receives new HTML, new JS files, and life goes on. But a modern React app often works as an SPA: one tab can stay open for hours or days. The user does not know that you have already shipped fixes, new translations, a different API schema, or a critical bug fix.
You need a system that can:
- Register a service worker only where it is actually needed.
- Detect that a new build is available.
- Expose the PWA state to the UI: registered, offline ready, update available, or failed.
- Update the app when the user clicks a button.
- Reset the service worker and Cache Storage if the user is stuck on an old version.
Below we will build that kind of system with React + Vite. It will be based on three pieces:
- `vite-plugin-pwa` configuration.
- A `ClassSw`class that manages the service worker lifecycle.
- `ProviderPWA`, which exposes the update state to React.
Why simply deploying a new build is not enough
Vite builds the app into static files with hashes:
index.html
assets/index-Bk4n8Nf3.js
assets/index-Cq81mA2x.css
When the user opens the site for the first time, the browser loads `index.html`, then JS and CSS. If the tab stays open, the old JS is already in memory. A new deployment will not make that JS disappear by itself.
A service worker adds another layer: it can cache application resources and serve them without going to the network. That is good for speed and offline mode, but bad if nobody manages updates.
So the goal is not to "enable PWA". The goal is to make the PWA controlled.
Overall architecture
The system consists of four layers:
- Build layer: Vite generates the service worker and web manifest.
- Runtime layer: `ClassSw`registers the service worker, listens for update events, and stores state.
- React layer: `ProviderPWA`synchronizes `ClassSw`state with React.
- UI layer: a button, toast, banner, or modal that offers the user an update.
In practice, the flow looks like this:
deploy new build
|
browser detects new service worker
|
ClassSw receives onNeedRefresh
|
ProviderPWA updates React context
|
UI shows "Update available"
|
user clicks update
|
updateSW(true) activates new SW and reloads page
Video demo: a side-by-side comparison of the Manual and Automatic update flows: Manual waits for user confirmation, while Automatic applies the update after onNeedRefresh.
Step 1
Install dependencies
We need `vite-plugin-pwa` and, optionally, `use-context-selector`, so React components can subscribe only to the PWA state fields they actually need.
yarn add vite-plugin-pwa use-context-selector
Or with npm:
npm install vite-plugin-pwa use-context-selector
Step 2
Add the build version
The app should know two versions:
- The current version that was baked into JS during the build
- The new version that can be read from a file next to the build.
It is convenient to pass the current version through env:
VITE_APP_VERSION=1.4.12
In code, you can collect this into one object:
const version = import.meta.env.VITE_APP_VERSION;
const mode = import.meta.env.VITE_NODE_ENV;
const baseURL = import.meta.env.VITE_BASE_URL;
export const env = {
version,
mode,
baseURL,
};
Next to the final build, place `build-info.txt`:
version: 1.4.13
commit: 8f4c9fd
builtAt: 2026-06-13T09:00:00Z
Important: the file must be available at `/build-info.txt`, because this is exactly what we will read when the service worker reports a new version.
In CI, it might look like this:
printf "version: %s\ncommit: %s\nbuiltAt: %s\n" "$APP_VERSION" "$GIT_SHA" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > build/build-info.txt
The exact way you create or update this file is not important. You can do it in CI, in a separate npm script, in a backend release process, with a shell command, or with a Vite plugin. The important part is that after deployment `/build-info.txt` returns information about the new build.
In my demo project, I use my own `pluginWriteBuildInfo` for this. It generates the file directly from the Vite config, so after `yarn build:prod` the fresh `build-info.txt` appears next to the build automatically.
For `build-info.txt`, it is better to configure short caching or `Cache-Control: no-cache`; otherwise, a CDN may return old version information.
Where exactly to configure this depends on what serves your `build`.
This is not a React setting, and usually not a `vite-plugin-pwa` setting either. After the build, the app is just a set of static files, so HTTP headers are configured at the static server, CDN, or deployment platform level.
For example, for Nginx:
location = /build-info.txt {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
For Netlify, you can describe this in a `_headers` file:
/build-info.txt
Cache-Control: no-cache, no-store, must-revalidate
For Vercel, use `vercel.json`:
{
"headers": [
{
"source": "/build-info.txt",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
}
]
}
If the build is stored in S3 behind CloudFront, the setup usually has two parts: set `Cache-Control` metadata on the `build-info.txt` object, and create a separate cache behavior for `/build-info.txt` in CloudFront with a minimal TTL or disabled caching.
Step 3
Configure Vite and PWA
The main magic lives in `vite.config.ts`.
Below is a simplified but production-ready configuration:
import { pluginWriteBuildInfo } from '@jenesei-software/jenesei-plugin-vite';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'node:path';
import process from 'node:process';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
const VITE_BASE_URL = env.VITE_BASE_URL;
const VITE_DEFAULT_NAME = env.VITE_DEFAULT_NAME;
const VITE_DEFAULT_NAME_SHORT = env.VITE_DEFAULT_NAME_SHORT;
const VITE_DEFAULT_THEME_COLOR = env.VITE_DEFAULT_THEME_COLOR;
const VITE_DEFAULT_DESCRIPTION = env.VITE_DEFAULT_DESCRIPTION;
const VITE_OUTPUT_DIR = env.VITE_OUTPUT_DIR || 'build';
const VITE_APP_VERSION = env.VITE_APP_VERSION || 'unknown';
const buildInfoPath = path.resolve(__dirname, VITE_OUTPUT_DIR, 'build-info.txt');
return {
build: {
outDir: VITE_OUTPUT_DIR,
},
plugins: [
react(),
VitePWA({
filename: 'vite-sw.js',
strategies: 'generateSW',
registerType: 'prompt',
injectRegister: null,
includeManifestIcons: false,
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,json}'],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: new RegExp(`^${VITE_BASE_URL}/.*$`),
handler: 'NetworkOnly',
},
{
urlPattern: /build-info\.txt$/,
handler: 'NetworkFirst',
options: {
cacheName: 'version-cache',
expiration: {
maxEntries: 1,
maxAgeSeconds: env.VITE_CACHE_VERSION_MAX_AGE_SECONDS
? parseInt(env.VITE_CACHE_VERSION_MAX_AGE_SECONDS, 10)
: 60 * 60 * 24,
},
},
},
],
},
devOptions: {
enabled: false,
},
manifest: {
display: 'standalone',
orientation: 'portrait',
name: VITE_DEFAULT_NAME,
short_name: VITE_DEFAULT_NAME_SHORT,
theme_color: VITE_DEFAULT_THEME_COLOR,
background_color: VITE_DEFAULT_THEME_COLOR,
description: VITE_DEFAULT_DESCRIPTION,
start_url: '/',
icons: [],
},
}),
pluginWriteBuildInfo({
pathBuildInfo: buildInfoPath,
version: VITE_APP_VERSION,
mode,
}),
],
};
});
There are several important decisions here.
`filename: 'vite-sw.js'` fixes the service worker file name. After the first production release, it is better not to change it without a migration plan: browsers already know the old service worker URL, and moving it can leave some users on the previous update scheme.
`strategies: 'generateSW'` tells the plugin to generate the service worker through Workbox. For most React apps, this is a good start: we do not write the service worker manually, but we still control its registration and runtime caching.
`registerType: 'prompt'` means the new version will not silently reload the page. Instead, `vite-plugin-pwa` will call `onNeedRefresh`, and we will decide how to show the update to the user.
`injectRegister: null` disables automatic service worker registration injection into HTML. Registration will be performed by our `ClassSw`. This makes the system transparent: all states pass through one class.
`NetworkOnly` for the API protects against accidentally caching backend responses. If the API should be cached, it is better to design that separately instead of leaving it to a generic PWA cache.
`NetworkFirst` for `build-info.txt` lets us check the network first and receive the fresh version. If the network is unavailable, Workbox can return the last cached information.
`pluginWriteBuildInfo` is an example of my approach to generating `build-info.txt`. You can update this file in any way that fits your release process; the service worker and React layer only depend on the file being available at `/build-info.txt` and containing a `version:` line.
`devOptions.enabled: false` disables the service worker in development mode. This is almost always the right choice: a service worker on localhost can easily create phantom bugs when you change code while the browser keeps living in an old cache.
Step 4
Describe virtual:pwa-register for TypeScript
If TypeScript cannot see the virtual module, add a declaration:
declare module 'virtual:pwa-register' {
export type RegisterSWOptions = {
immediate?: boolean;
onNeedRefresh?: () => void | Promise<void>;
onOfflineReady?: () => void | Promise<void>;
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void | Promise<void>;
onRegisterError?: (error: unknown) => void | Promise<void>;
};
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>;
}
`registerSW()` returns an `updateSW` function. We will store it and call it when the user clicks "Update".
Step 5
Write ClassSw
`ClassSw` is a small external store for the service worker.
Why not put everything straight into a React component? Because a service worker does not live by React rules. It has its own lifecycle, registration, reset, caches, and browser errors. It is more convenient to keep that machinery separate and use React only as the rendering layer.
First, describe the statuses:
type IClassSwUpdateFn = (reloadPage?: boolean) => Promise<void>;
type IClassSwChangeListener = () => void;
type IClassSwStatus =
| 'disabled'
| 'disabling'
| 'idle'
| 'registering'
| 'resetting'
| 'registered'
| 'unregistered'
| 'offline-ready'
| 'update-available'
| 'error';
export interface IClassSwInitOptions {
/** Enables or disables service worker registration for the app. */
enabled?: boolean;
/** Registers the service worker immediately after initialization. */
immediate?: boolean;
}
export interface IClassSwResetCacheOptions {
/** Reloads the page after the cache is cleared so SW registration restarts from a clean slate. */
reloadPage?: boolean;
/** Re-registers the service worker in-place when a full page reload is not desired. */
reRegister?: boolean;
}
export interface IClassSwSnapshot {
/** Current lifecycle status of the service worker manager. */
status: IClassSwStatus;
/** Whether service worker support is enabled in app configuration. */
isEnabled: boolean;
/** Whether the current browser supports service workers. */
isSupported: boolean;
/** Whether the service worker manager has already been initialized. */
isInitialized: boolean;
/** Whether a service worker registration is currently active. */
isRegistered: boolean;
/** Whether the app is ready to work offline. */
isOfflineReady: boolean;
/** Whether a newer service worker version is available. */
isUpdateAvailable: boolean;
/** Whether the UI should prompt the user to refresh the page. */
isNeedRefresh: boolean;
/** Current application version reported by the active build. */
currentVersion: string | null;
/** Version detected for the newly available service worker build. */
newVersion: string | null;
/** Scope returned by the browser for the current service worker registration. */
registrationScope: string | null;
/** Last known service worker error message, if any. */
error: string | null;
}
Now the class itself:
import { env } from '@local/core/envs';
import { logger } from '@local/core/logger';
import { registerSW } from 'virtual:pwa-register';
export class ClassSw {
private updateSW: IClassSwUpdateFn | null = null;
private initOptions: IClassSwInitOptions = {};
// These fields are the single source of truth for SW/PWA state inside the app.
private valueStatus: IClassSwStatus = 'idle';
private valueIsEnabled = true;
private valueIsSupported = typeof window !== 'undefined' && 'serviceWorker' in navigator;
private valueIsInitialized = false;
private valueIsRegistered = false;
private valueIsUpdateAvailable = false;
private valueIsOfflineReady = false;
private valueIsNeedRefresh = false;
private valueCurrentVersion: string | null = null;
private valueNewVersion: string | null = null;
private valueRegistrationScope: string | null = null;
private valueError: string | null = null;
private listeners: IClassSwChangeListener[] = [];
init(options?: IClassSwInitOptions) {
if (this.valueIsInitialized) return;
this.valueIsInitialized = true;
this.initOptions = options ?? {};
this.valueIsEnabled = options?.enabled ?? true;
if (!this.valueIsEnabled) {
if (!this.valueIsSupported) {
this.valueStatus = 'disabled';
this.notify();
return;
}
// When PWA is disabled we still need to clean up a previously installed SW.
this.valueStatus = 'disabling';
this.notify();
void this.disableServiceWorker();
return;
}
if (!this.valueIsSupported) {
this.valueStatus = 'error';
this.valueError = 'Service workers are not supported in this browser.';
this.notify();
return;
}
this.registerServiceWorker();
}
setCurrentVersion(version: string | null) {
this.valueCurrentVersion = version;
this.notify();
}
async updateApp(reloadPage = true) {
try {
if (typeof this.updateSW === 'function') {
await this.updateSW(reloadPage);
}
} catch {
logger.warn('updateSW() called before SW initialized');
}
}
async resetAppCache(options?: IClassSwResetCacheOptions) {
if (!this.valueIsSupported) {
this.valueStatus = 'error';
this.valueError = 'Service workers are not supported in this browser.';
this.notify();
return;
}
const reloadPage = options?.reloadPage ?? true;
const reRegister = options?.reRegister ?? !reloadPage;
this.valueStatus = 'resetting';
this.valueError = null;
this.notify();
try {
const hasStorageToCleanup = await this.clearServiceWorkerStorage();
if (!this.valueIsEnabled) {
this.valueStatus = hasStorageToCleanup ? 'unregistered' : 'disabled';
this.notify();
return;
}
if (reloadPage) {
window.location.reload();
return;
}
if (reRegister) {
this.registerServiceWorker();
return;
}
this.valueStatus = hasStorageToCleanup ? 'unregistered' : 'idle';
this.notify();
} catch (error) {
this.valueStatus = 'error';
this.valueError = error instanceof Error ? error.message : 'Service worker reset failed.';
this.notify();
throw error;
}
}
get isUpdateAvailable() {
return this.valueIsUpdateAvailable;
}
get isEnabled() {
return this.valueIsEnabled;
}
get isSupported() {
return this.valueIsSupported;
}
get isInitialized() {
return this.valueIsInitialized;
}
get isRegistered() {
return this.valueIsRegistered;
}
get isOfflineReady() {
return this.valueIsOfflineReady;
}
get isNeedRefresh() {
return this.valueIsNeedRefresh;
}
get status() {
return this.valueStatus;
}
get currentVersion() {
return this.valueCurrentVersion;
}
get newVersion() {
return this.valueNewVersion;
}
get registrationScope() {
return this.valueRegistrationScope;
}
get error() {
return this.valueError;
}
get snapshot(): IClassSwSnapshot {
return {
status: this.valueStatus,
isEnabled: this.valueIsEnabled,
isSupported: this.valueIsSupported,
isInitialized: this.valueIsInitialized,
isRegistered: this.valueIsRegistered,
isOfflineReady: this.valueIsOfflineReady,
isUpdateAvailable: this.valueIsUpdateAvailable,
isNeedRefresh: this.valueIsNeedRefresh,
currentVersion: this.valueCurrentVersion,
newVersion: this.valueNewVersion,
registrationScope: this.valueRegistrationScope,
error: this.valueError,
};
}
subscribe(cb: IClassSwChangeListener) {
this.listeners.push(cb);
return () => {
this.listeners = this.listeners.filter((l) => l !== cb);
};
}
private notify() {
// ClassSw is consumed as a small external store, so every mutation fan-outs here.
this.listeners.forEach((cb) => {
try {
cb();
} catch {
/* ignore listener errors */
}
});
}
private async readBuildInfoVersion() {
const url = new URL(`${env.basePath}build-info.txt`, window.location.origin);
url.searchParams.set('sw-update', Date.now().toString());
const res = await fetch(url, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
});
if (!res.ok) return null;
const text = await res.text();
const versionLine = text
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('version:'));
const version = versionLine?.slice('version:'.length).trim() ?? null;
return version && version !== this.valueCurrentVersion ? version : null;
}
private async handleNeedRefresh() {
this.valueIsUpdateAvailable = true;
this.valueStatus = 'update-available';
this.notify();
try {
this.valueNewVersion = await this.readBuildInfoVersion();
this.valueIsNeedRefresh = true;
} catch {
this.valueNewVersion = null;
this.valueIsNeedRefresh = true;
}
this.notify();
}
private handleOfflineReady() {
this.valueIsOfflineReady = true;
this.valueIsNeedRefresh = false;
this.valueStatus = 'offline-ready';
this.notify();
}
private registerServiceWorker() {
this.valueIsRegistered = false;
this.valueRegistrationScope = null;
this.valueIsOfflineReady = false;
this.valueIsUpdateAvailable = false;
this.valueIsNeedRefresh = false;
this.valueNewVersion = null;
this.valueError = null;
this.valueStatus = 'registering';
this.notify();
this.updateSW = registerSW({
immediate: this.initOptions.immediate,
onNeedRefresh: () => void this.handleNeedRefresh(),
onOfflineReady: () => void this.handleOfflineReady(),
onRegistered: (registration) => {
this.valueIsRegistered = Boolean(registration);
this.valueRegistrationScope = registration?.scope ?? null;
this.valueStatus = registration ? 'registered' : 'idle';
this.valueError = null;
this.notify();
},
onRegisterError: (error) => {
this.valueStatus = 'error';
this.valueError = error instanceof Error ? error.message : 'Service worker registration failed.';
this.notify();
},
});
}
private async disableServiceWorker() {
try {
const hasStorageToCleanup = await this.clearServiceWorkerStorage();
this.valueStatus = hasStorageToCleanup ? 'unregistered' : 'disabled';
} catch (error) {
this.valueStatus = 'error';
this.valueError = error instanceof Error ? error.message : 'Service worker cleanup failed.';
}
this.notify();
}
private async clearServiceWorkerStorage() {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
let cacheKeys: string[] = [];
if (typeof caches !== 'undefined') {
// We clear caches together with unregister so stale offline data does not survive between installs.
cacheKeys = await caches.keys();
await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey)));
}
this.updateSW = null;
this.valueIsRegistered = false;
this.valueRegistrationScope = null;
this.valueIsOfflineReady = false;
this.valueIsUpdateAvailable = false;
this.valueIsNeedRefresh = false;
this.valueNewVersion = null;
this.valueError = null;
return registrations.length > 0 || cacheKeys.length > 0;
}
}
The main idea of the class is that every state change goes through `notify()`. React does not know anything about the details of `registerSW`, `onNeedRefresh`, `Cache Storage`, or `navigator.serviceWorker`. It simply subscribes to an external store.
Pay special attention to `resetAppCache()`. This is not a cosmetic function. It is needed when the user gets stuck between versions: the service worker is already installed, the cache contains old assets, and the app behaves strangely. A "Reset App Cache" button often saves hours of support work.
Step 6
Create a singleton for ClassSw
The service worker should be controlled by one `ClassSw` instance. It is better to move it into a separate module:
import { ClassSw } from './classes/class-sw';
export const swService = new ClassSw();
Now this singleton can be used both in `main.tsx` and in the React provider without circular imports.
In `main.tsx`, initialize the service before rendering the app:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app';
import { env } from './core/envs';
import { swService } from './services/sw-service';
try {
swService.init({
enabled: env.mode === 'prod',
});
swService.setCurrentVersion(env.version ?? null);
} catch (error) {
console.warn('SW init failed', error);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
Here the service worker is enabled only in `prod`. In `dev` and `stage`, it can be disabled to avoid bugs caused by old caches. If staging should behave like production, replace the condition with:
enabled: env.mode === 'prod' || env.mode === 'stage',
Step 7
Create ProviderPWA
Now we need to pass the state into React.
The context will store:
- Service worker statuses.
- The current and new version.
- `updateApp()` and `resetAppCache()` methods.
import { swService } from './services/sw-service';
import { FC, PropsWithChildren, useCallback, useEffect, useState } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
type PWAStatus =
| 'disabled'
| 'disabling'
| 'idle'
| 'registering'
| 'resetting'
| 'registered'
| 'unregistered'
| 'offline-ready'
| 'update-available'
| 'error';
interface PWAContextValue {
status: PWAStatus;
isEnabled: boolean;
isSupported: boolean;
isInitialized: boolean;
isRegistered: boolean;
isOfflineReady: boolean;
isUpdateAvailable: boolean;
isNeedRefresh: boolean;
newVersion: string | null;
currentVersion: string | null;
registrationScope: string | null;
error: string | null;
updateApp: () => void;
resetAppCache: () => void;
}
const PWAContext = createContext<PWAContextValue | null>(null);
export const usePWA = <T extends keyof PWAContextValue>(props: T[]): Pick<PWAContextValue, T> => {
const context = useContextSelector(PWAContext, (value) => {
return value
? props.reduce(
(acc, prop) => {
acc[prop] = value[prop];
return acc;
},
{} as Pick<PWAContextValue, T>,
)
: null;
});
if (!context) {
throw new Error('usePWA must be used within ProviderPWA');
}
return context;
};
export const ProviderPWA: FC<PropsWithChildren> = ({ children }) => {
const [status, setStatus] = useState<PWAStatus>('idle');
const [isEnabled, setIsEnabled] = useState(true);
const [isSupported, setIsSupported] = useState(true);
const [isInitialized, setIsInitialized] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const [isOfflineReady, setIsOfflineReady] = useState(false);
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
const [isNeedRefresh, setIsNeedRefresh] = useState(false);
const [newVersion, setNewVersion] = useState<string | null>(null);
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const [registrationScope, setRegistrationScope] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const syncState = () => {
setStatus(swService.status);
setIsEnabled(swService.isEnabled);
setIsSupported(swService.isSupported);
setIsInitialized(swService.isInitialized);
setIsRegistered(swService.isRegistered);
setIsOfflineReady(swService.isOfflineReady);
setIsUpdateAvailable(swService.isUpdateAvailable);
setIsNeedRefresh(swService.isNeedRefresh);
setNewVersion(swService.newVersion ?? null);
setCurrentVersion(swService.currentVersion ?? null);
setRegistrationScope(swService.registrationScope ?? null);
setError(swService.error ?? null);
};
syncState();
const unsubscribe = swService.subscribe(syncState);
return () => unsubscribe();
}, []);
const updateApp = useCallback(() => {
try {
swService.updateApp().catch(() => window.location.reload());
} catch {
window.location.reload();
}
}, []);
const resetAppCache = useCallback(() => {
try {
swService.resetAppCache().catch(() => window.location.reload());
} catch {
window.location.reload();
}
}, []);
return (
<PWAContext.Provider
value={{
status,
isEnabled,
isSupported,
isInitialized,
isRegistered,
isOfflineReady,
isUpdateAvailable,
isNeedRefresh,
newVersion,
currentVersion,
registrationScope,
error,
updateApp,
resetAppCache,
}}
>
{children}
</PWAContext.Provider>
);
};
Here `ProviderPWA` mirrors the `ClassSw` state into React. You could use `useSyncExternalStore`, but an explicit provider is convenient if you want to expose the state through an existing app context system.
`use-context-selector` is needed so a component that only needs `isUpdateAvailable` does not rerender because `registrationScope` or `currentVersion` changed.
Step 8
Wrap the app in ProviderPWA
In the root component:
import { ProviderPWA } from './contexts/context-pwa';
import { LayoutRouter } from './layouts/layout-router';
function App() {
return (
<ProviderPWA>
<LayoutRouter />
</ProviderPWA>
);
}
export default App;
The provider should be high enough in the tree for the update banner to be available on every page.
Step 9
Add update UI
A minimal version:
import { usePWA } from './contexts/context-pwa';
export function UpdateAppButton() {
const pwa = usePWA(['isUpdateAvailable', 'newVersion', 'updateApp', 'resetAppCache']);
return (
<div>
<button disabled={!pwa.isUpdateAvailable} type="button" onClick={pwa.updateApp}>
{pwa.isUpdateAvailable ? `Update to ${pwa.newVersion ?? 'new version'}` : 'No updates'}
</button>
<button type="button" onClick={pwa.resetAppCache}>
Reset App Cache
</button>
</div>
);
}
In production, it is better to show a toast or sticky banner instead of just a button in the layout:
import { usePWA } from './contexts/context-pwa';
export function UpdateAppBanner() {
const pwa = usePWA(['isNeedRefresh', 'currentVersion', 'newVersion', 'updateApp']);
if (!pwa.isNeedRefresh) return null;
return (
<section role="status" aria-live="polite">
<p>
A new version is available
{pwa.newVersion ? `: ${pwa.newVersion}` : ''}.
</p>
{pwa.currentVersion && <small>Current version: {pwa.currentVersion}</small>}
<button type="button" onClick={pwa.updateApp}>
Refresh
</button>
</section>
);
}
The copy can be adapted to the product. The important thing is not to reload the page without warning if the user could be filling out a form or performing a long-running action.
What happens during an update
When a new build is released, the browser/service worker pipeline goes through several stages:
- The browser sees that the service worker file has changed.
- The new service worker is installed but does not take control immediately.
- `vite-plugin-pwa` calls `onNeedRefresh`.
- `ClassSw` sets `isUpdateAvailable = true`.
- `ClassSw` reads `/build-info.txt` and saves `newVersion`.
- React receives the new state through `ProviderPWA`.
- The user clicks "Refresh".
- `updateSW(true)` activates the new service worker and reloads the page.
Important: this list does not start at deployment time. It starts when the browser has already decided to check the service worker and has seen the new file. An already-open idle tab is not guaranteed to discover the deployment immediately. In the base implementation from this article, detection usually happens after a reload, in-app navigation, reopening the tab, or another browser service worker update check. If you need active tabs to discover updates within N minutes, add explicit polling: `registration.update()` or a separate `build-info.txt` version check.
Thanks to `registerType: 'prompt'`, the moment when the new version is activated is controlled by the app, not by the plugin. The developer decides what to do after `onNeedRefresh`: show the user a banner, delay the update until a safe moment, update after the current flow is complete, or call `updateApp()` immediately. This is especially important for dashboards, CRMs, editors, carts, forms, and any app with unsaved state.
How to test
PWA updates should be tested on a production build. In development mode, the service worker is disabled.
An example scenario:
- Build the app with version `1.0.0`.
- Start a production preview or serve the build with a static server.
- Open the app in the browser and make sure the service worker is registered.
- Build a new version, `1.0.1`.
- Replace the files on the server without closing the tab.
- Navigate inside the app or reload the tab in a way that makes the browser check the service worker.
- Make sure the update banner appears.
- Click "Refresh".
- Verify that the app has loaded with the new version.
If you simply leave the old tab idle, the banner may not appear immediately. In this article, `Automatic` mode means "automatically apply the update after `onNeedRefresh`", not "poll the server from the active tab."
For local testing, you can use:
yarn build:prod
npx vite preview --outDir build
In Chrome, the service worker state is convenient to inspect in DevTools:
Application -> Service workers
Application -> Cache storage
There you can also manually unregister the service worker, clear storage, and see which service worker currently controls the page.
Common mistakes
An update exists, but the UI does not show it.
Check that service worker registration goes through your `ClassSw`, and that `ProviderPWA` is actually subscribed to `swService.subscribe()`. Also make sure the app is running in a mode where `enabled: true`.
The user sees the old version even after deployment.
This is normal until the new service worker has been detected and activated. If it is not detected at all, check `filename`, registration scope, and the HTTP cache for the service worker file.
`build-info.txt` shows the old version.
Most likely, the file is cached by a CDN or server. It is better to use `Cache-Control: no-cache` or a very short TTL for it. In Workbox, we already set `NetworkFirst`, but that does not override external CDN behavior.
API responses suddenly became stale.
Check runtime caching. In the example, the API is sent to `NetworkOnly` so the service worker does not cache backend data.
There are constant strange bugs on localhost after changing PWA code.
Clear the service worker and Cache Storage in DevTools. In development, it is better to keep `devOptions.enabled: false`.
The user is completely stuck.
This is what `resetAppCache()` is for: unregister all service workers, delete Cache Storage, and reload the page. It is a blunt but useful emergency exit.
Conclusion
A good update system for a React app is not one `vite-plugin-pwa` setting.
A reliable setup consists of several parts:
- Vite generates the service worker and manifest.
- `registerType: 'prompt'` gives the app control over the update moment.
- `ClassSw` stores service worker state and can update or reset the app.
- `ProviderPWA` turns that external store into a convenient React API.
- The UI shows the user a clear action: "a new version is available, update now".
As a result, deployment stops being a lottery: when the browser detects a new service worker, the app exposes a clear state and offers a safe way to switch to the new build. If your product needs to discover deployments in a long-lived active tab without any user action, add that polling layer explicitly.