← Back to list

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.

Cyril Strone · 2026-06-13 19:38 · 5 claps · 16.4 min read
#react #pwa #service-worker #vitejs
Open on Medium ↗
Wiki topics: 🌐 · Web Development

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:

  1. Register a service worker only where it is actually needed.
  2. Detect that a new build is available.
  3. Expose the PWA state to the UI: registered, offline ready, update available, or failed.
  4. Update the app when the user clicks a button.
  5. 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:

  1. vite-plugin-pwa configuration.
  2. A ClassSw class that manages the service worker lifecycle.
  3. 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:

  1. Build layer: Vite generates the service worker and web manifest.
  2. Runtime layer: ClassSw registers the service worker, listens for update events, and stores state.
  3. React layer: ProviderPWA synchronizes ClassSw state with React.
  4. 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

Result example

[embed]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:

  1. The current version that was baked into JS during the build
  2. 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:

  1. Service worker statuses.
  2. The current and new version.
  3. 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.

When a new build is released, the browser/service worker pipeline goes through several stages:

  1. The browser sees that the service worker file has changed.
  2. The new service worker is installed but does not take control immediately.
  3. vite-plugin-pwa calls onNeedRefresh.
  4. ClassSw sets isUpdateAvailable = true.
  5. ClassSw reads /build-info.txt and saves newVersion.
  6. React receives the new state through ProviderPWA.
  7. The user clicks “Refresh”.
  8. 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:

  1. Build the app with version 1.0.0.
  2. Start a production preview or serve the build with a static server.
  3. Open the app in the browser and make sure the service worker is registered.
  4. Build a new version, 1.0.1.
  5. Replace the files on the server without closing the tab.
  6. Navigate inside the app or reload the tab in a way that makes the browser check the service worker.
  7. Make sure the update banner appears.
  8. Click “Refresh”.
  9. 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

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:

  1. Vite generates the service worker and manifest.
  2. registerType: ‘prompt’ gives the app control over the update moment.
  3. ClassSw stores service worker state and can update or reset the app.
  4. ProviderPWA turns that external store into a convenient React API.
  5. 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.

Links

Demo

Source Code

Originally published at https://blog.cyrilstrone.com on June 13, 2026.


메타데이터
post_id
f487a08f8422
slug
react-pwa-update-system-f487a08f8422
url
https://medium.com/@cyrilstrone/react-pwa-update-system-f487a08f8422
canonical_url
https://medium.com/@cyrilstrone/react-pwa-update-system-f487a08f8422
author_url
https://medium.com/@cyrilstrone
status
ok
fetched_at
2026-07-15 12:46:07