EmpireUI
Get Pro
← Blog9 min read#workbox#pwa#service worker

Workbox Service Worker in React: Caching Strategies for PWA

Set up Workbox in a React PWA with real caching strategies — cache-first, network-first, stale-while-revalidate — and ship offline support that actually works.

Abstract digital network cache layers representing PWA service worker caching

Why Workbox — and Why It's Worth the Setup

Service workers are not complicated. They're just JavaScript running in a separate thread, intercepting network requests and deciding what to do with them. The hard part is writing all the cache logic by hand — expiration policies, cache versioning, background sync, fallback pages — and doing it without subtle bugs that silently break your app for users on spotty 4G.

Workbox, Google's PWA library (currently at version 7), handles all of that boilerplate. You declare strategies, Workbox wires the service worker internals. You stop writing caches.open() in raw fetch event handlers and start thinking at the level of: *should this route be cache-first or network-first?* That's the right level of abstraction.

In practice, most React projects using Vite reach for vite-plugin-pwa, which wraps Workbox and injects a service worker during your build. Create React App has cra-template-pwa. Both use Workbox under the hood, so the strategies covered here apply to either. This article leans on the Vite path because that's where new projects land in 2026.

Worth noting: Workbox doesn't make your app magically offline. It caches what you tell it to cache. If you skip configuring routes for your API calls, users without a connection still get a blank screen. The strategy decisions are what matter — and that's exactly what we're going to work through.

Setting Up Workbox in a Vite + React Project

Install the plugin and add it to your Vite config. Nothing exotic here.

npm install -D vite-plugin-pwa
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        // routes handled by Workbox strategies go here
        runtimeCaching: [],
      },
      manifest: {
        name: 'My React PWA',
        short_name: 'ReactPWA',
        theme_color: '#0f0f0f',
        icons: [
          { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
    }),
  ],
});

The registerType: 'autoUpdate' tells Workbox to skip waiting and activate a new service worker as soon as it's available, rather than waiting for all tabs to close. For most apps that's the right call. If you're running something that can't handle mid-session updates — say, a multi-step checkout — swap in 'prompt' and show an update banner yourself.

After npm run build, Vite generates a sw.js in your dist/ folder and injects the registration script. Open DevTools > Application > Service Workers to verify it's installed. You should also see a Cache Storage section filling up with your precached assets.

The Four Caching Strategies You'll Actually Use

Workbox ships five named strategies. You'll realistically use four of them. Here's the honest breakdown:

Cache First — serve from cache, only hit the network if the cache misses. Best for static assets that don't change between builds: fonts, vendor JS bundles, images. A font request hitting the network every single page load is 200–400ms you're throwing away for zero benefit. Put fonts at 30 days expiration and never think about it again.

Network First — try the network, fall back to cache on failure. Use this for API endpoints where you want fresh data but need something to show offline. Your dashboard's /api/metrics endpoint? Network-first with a 10-second timeout and a stale cache fallback keeps the app usable when the WiFi drops mid-morning.

Stale While Revalidate — serve cache immediately, then fetch fresh data in the background and update the cache for next time. This is the sweet spot for content that changes occasionally but doesn't need to be pixel-perfect on first paint: navigation menus, user profile data, blog post listings. The user sees something instantly, and the next load gets fresher data.

Cache Only — never touch the network. Useful for assets you've explicitly precached during install and never want to re-fetch. Your app shell's index.html, your offline fallback page. Combine this with Workbox's precacheAndRoute and it just works.

// workbox runtimeCaching config inside vite.config.ts
runtimeCaching: [
  {
    // Fonts — cache-first, 30 days
    urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
    handler: 'CacheFirst',
    options: {
      cacheName: 'google-fonts-cache',
      expiration: { maxEntries: 10, maxAgeSeconds: 30 * 24 * 60 * 60 },
      cacheableResponse: { statuses: [0, 200] },
    },
  },
  {
    // API — network-first, fallback to cache
    urlPattern: /^\/api\/.*/i,
    handler: 'NetworkFirst',
    options: {
      cacheName: 'api-cache',
      networkTimeoutSeconds: 10,
      expiration: { maxEntries: 50, maxAgeSeconds: 5 * 60 },
      cacheableResponse: { statuses: [0, 200] },
    },
  },
  {
    // Images — stale-while-revalidate
    urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
    handler: 'StaleWhileRevalidate',
    options: {
      cacheName: 'images-cache',
      expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60 },
    },
  },
],

Precaching Your App Shell

Runtime caching handles requests that happen while the app is running. Precaching is different — it downloads and stores assets *during service worker installation*, before any user interaction happens. Your JS bundles, CSS, and index.html should be precached so the app shell loads instantly even offline.

With vite-plugin-pwa, precaching is automatic. Every file in your build output gets a revision hash and lands in the precache manifest. When you deploy a new build, Workbox compares hashes and only re-downloads files that actually changed. No cache busting headaches.

One thing to configure manually: your offline fallback page. Create a /offline.html in your public/ folder and register it as the fallback for navigation requests that fail both network and cache.

// Inside VitePWA config
workbox: {
  navigateFallback: '/index.html',
  navigateFallbackDenylist: [/^\/api/, /\/sw\.js$/],
  runtimeCaching: [ /* ... */ ],
},

The navigateFallbackDenylist is important. Without it, failed API calls get served your React app's index.html, which confuses client code expecting JSON. Keep API routes out of the fallback path.

Background Sync for Offline Form Submissions

Here's a scenario that trips people up. User fills out a contact form. They're on a train. Connection drops right as they hit submit. What happens? Without background sync, the request fails silently and they've lost the data. With Workbox's BackgroundSyncPlugin, the request queues in IndexedDB and replays automatically when connectivity returns.

// src/sw-custom.ts — loaded via workbox injectManifest mode
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
  maxRetentionTime: 24 * 60, // 24 hours in minutes
});

registerRoute(
  ({ url }) => url.pathname === '/api/contact',
  new NetworkOnly({ plugins: [bgSyncPlugin] }),
  'POST'
);

To use injectManifest mode instead of generateSW, update your Vite config: set strategies: 'injectManifest' and point srcDir / filename at your custom service worker file. This gives you full control — Workbox injects the precache manifest into your file, and you write the rest yourself.

Honestly, background sync is one of those features that sounds like a nice-to-have until the first time you lose a form submission and have to explain it to a client. Bake it in early.

Quick aside: the sync event replays in the background, not immediately on page load. If you need to show the user "your message was sent" after connectivity returns, you'll want to combine this with a BroadcastChannel message from the service worker back to the page.

Debugging Your Service Worker Without Losing Your Mind

Service worker bugs are uniquely painful because the caching layer sits between your app and the network, and it persists across page loads. You change your fetch strategy, refresh the page, and the *old* service worker is still running. You're debugging code that isn't running anymore. Sound familiar?

The fix: during development, disable the service worker entirely. Add enabled: process.env.NODE_ENV === 'production' to your VitePWA config. No caching in dev means no stale-code confusion. Test the service worker in a separate npm run preview session against the production build.

VitePWA({
  // Only activate SW in production
  selfDestroying: process.env.NODE_ENV !== 'production',
  // ... rest of config
})

In Chrome DevTools, the Application > Service Workers panel has an "Update on reload" checkbox. Check it. Also know the "Clear site data" button — it nukes the cache, unregisters the SW, and gives you a clean slate. You'll use it constantly while iterating on cache strategies.

Workbox also ships a debug build (automatically used when NODE_ENV is 'development') that logs every cache hit and miss to the console. You'll see [workbox] Using CacheFirst to respond to... for each intercepted request. Noisy, but invaluable when you're verifying your route patterns are actually matching. The patterns are RegExp — test them against RegExp.prototype.test() before committing.

Putting It Together: PWA + a Polished UI

A PWA with great caching still needs a great UI. The install experience, the offline state UI, the update notification banner — these are the moments where users form impressions. They shouldn't look like an afterthought.

For the update prompt (the "new version available — reload?" banner you show when a new SW activates), keep it lightweight. A small sticky bar at 48px tall with a reload button is enough. You can pull the service worker registration hook from vite-plugin-pwa/react to detect when needRefresh is true and mount your banner conditionally.

// UpdatePrompt.tsx
import { useRegisterSW } from 'virtual:pwa-register/react';

export function UpdatePrompt() {
  const { needRefresh: [needRefresh], updateServiceWorker } = useRegisterSW();

  if (!needRefresh) return null;

  return (
    <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 px-4 py-3 bg-gray-900 text-white rounded-xl shadow-xl flex gap-4 items-center">
      <span className="text-sm">Update available</span>
      <button
        onClick={() => updateServiceWorker(true)}
        className="text-sm font-medium text-violet-400 hover:text-violet-300"
      >
        Reload
      </button>
    </div>
  );
}

For offline state detection, the navigator.onLine API is unreliable in practice — it returns true even on captive portal WiFi with no actual internet. Listen to fetch failures in your data layer instead and surface an offline indicator when requests fail consistently. Pair that with a dedicated offline fallback page that matches your app's visual style.

Look, the UI layer of your PWA is where Empire UI can save you real time. The component library includes notification toasts, banners, and modal patterns that drop into a PWA update flow without styling from scratch. If your app leans into a bold aesthetic — think the neobrutalism or glassmorphism styles — you can grab matching offline state UI and install prompt components from the library and keep your PWA's identity consistent end to end. The glassmorphism generator is also handy for quickly generating the exact backdrop-filter values you want for overlay banners.

FAQ

What's the difference between generateSW and injectManifest mode in Workbox?

generateSW auto-generates a full service worker file from your config — least code, least control. injectManifest takes your custom SW file and injects Workbox's precache manifest into it, letting you write your own routing logic. Use injectManifest once you need background sync, push notifications, or anything beyond basic caching.

Can I use Workbox with Next.js instead of Vite?

Yes — next-pwa wraps Workbox for Next.js apps and supports the same runtime caching config. The concepts are identical; only the config location changes. Check the next-pwa docs for the runtimeCaching options, which mirror Workbox's directly.

How do I stop Workbox from caching my API responses too aggressively?

Set a short maxAgeSeconds on your API cache entry and use NetworkFirst strategy. Add a networkTimeoutSeconds of 5–10 seconds so it falls back to cache only on actual connectivity failures, not slow servers.

Does Workbox work with React Query or SWR?

They operate at different layers and don't conflict. React Query and SWR cache in memory during a session; Workbox caches at the network layer across sessions. You can run both together — React Query handles in-app data freshness while Workbox handles offline resilience.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Service Workers in Next.js: Offline Support, Background SyncNx Monorepo Guide: Affected Commands, Caching and React LibrariesProgressive Web Apps with React and Next.js in 2026Push Notifications in React: Service Worker, Permission Flow