Skip to main content
← All Articles Β· Β· 15 min read

Building Production-Ready PWAs with Workbox in 2026

Complete guide to Progressive Web Apps with Workbox in 2026. Master Service Worker lifecycle, caching strategies, offline-first patterns, Web App Manifest, and push notifications. Real code examples included.

pwaworkboxservice-workerprogressive-web-appoffline-firstweb-performancejavascript2026

Progressive Web Apps have a reputation for being complicated to get right. The theory sounds simple β€” a web app that works offline, installs on the home screen, and feels native β€” but implementing it correctly involves Service Worker lifecycle subtleties, cache invalidation pitfalls, and manifest quirks that can turn a weekend project into a debugging marathon.

Workbox is Google’s answer to that complexity. It abstracts the hard parts of Service Workers into a high-level API with battle-tested defaults, while staying flexible enough for advanced use cases. In 2026, Workbox v7 is stable, well-documented, and used by some of the largest web properties on the planet.

This guide covers everything: fundamentals, Service Worker lifecycle, every major Workbox strategy, offline-first patterns, and a complete working example.


PWA Fundamentals

A Progressive Web App is defined by three core requirements:

  1. HTTPS β€” Service Workers only register on secure origins (localhost is the exception)
  2. Service Worker β€” a JavaScript file running in a background thread, intercepting network requests
  3. Web App Manifest β€” a JSON file describing how the app appears when installed

Beyond the minimum, production PWAs also add push notifications, background sync, and periodic background sync. Let’s start from the foundation.

What Makes a Good PWA?

Chrome’s PWA criteria (required for install prompts):

  • Served over HTTPS
  • Has a valid manifest with name, icons (192px + 512px), start_url, display: standalone
  • Has a registered Service Worker

Lighthouse’s full PWA audit goes further β€” offline support, reasonable response times, meta viewport, and more. A passing Lighthouse PWA score of 100 is a solid target.


Service Worker Lifecycle

The Service Worker lifecycle is the source of most PWA confusion. Understanding it removes the mystery.

States

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  Parsed  β”‚ (downloaded, evaluated)
                    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
                         β”‚ install event
                    β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
                    β”‚ Installingβ”‚
                    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
                         β”‚ activate event
                    β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
     fetch events ──│  Active  │── idle
                    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
                         β”‚ new SW detected
                    β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
                    β”‚ Waiting  β”‚ (new SW waiting for old to release)
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Waiting Problem

When you deploy a new Service Worker, the old one keeps running until all tabs using it are closed. This is safe but annoying β€” users on old tabs block the update.

The fix: skipWaiting() + clients.claim():

// sw.js
self.addEventListener('install', (event) => {
  self.skipWaiting(); // Activate immediately
});

self.addEventListener('activate', (event) => {
  event.waitUntil(clients.claim()); // Take control of all pages
});

Use skipWaiting for non-critical updates. For breaking changes, show a β€œNew version available β€” reload” banner instead.

Registration

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      console.log('SW registered:', registration.scope);
    } catch (err) {
      console.error('SW registration failed:', err);
    }
  });
}

Always register after load β€” don’t block initial page render.


Introduction to Workbox

Workbox is a set of JavaScript libraries for adding offline support to web apps. At its core, it provides:

  • Routing β€” match requests by URL, type, or custom logic
  • Strategies β€” pre-built caching patterns (cache-first, network-first, etc.)
  • Precaching β€” cache-bust static assets at build time
  • Expiration β€” limit cache size and age automatically
  • Background Sync β€” retry failed requests when back online
  • Broadcast Cache Update β€” notify pages when cached assets update

Installation

npm install workbox-window workbox-routing workbox-strategies \
  workbox-precaching workbox-expiration workbox-background-sync

Or use the CDN in the Service Worker:

// sw.js β€” CDN approach (simpler, slightly larger)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.3.0/workbox-sw.js');

For production, use npm + a bundler. For prototyping, the CDN is fine.


Workbox Caching Strategies

Workbox ships five caching strategies. Choosing the right one for each resource type is the key skill.

1. CacheFirst

Check cache first. If found, return it. If not, fetch and cache.

Use for: Static assets that change infrequently β€” fonts, images, versioned JS/CSS.

import { registerRoute } from 'workbox-routing';
import { CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images-v1',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,          // Keep only 60 images
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
      }),
    ],
  })
);

Risk: Users see stale content if you don’t version your assets. Always use content hashing in filenames (Webpack/Vite do this automatically).

2. NetworkFirst

Try the network first. On failure, fall back to cache.

Use for: HTML pages, API responses β€” content that should be fresh but must work offline.

import { NetworkFirst } from 'workbox-strategies';

registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages-v1',
    networkTimeoutSeconds: 3, // Fall back to cache after 3s
    plugins: [
      new ExpirationPlugin({ maxEntries: 25 }),
    ],
  })
);

3. StaleWhileRevalidate

Return cached version immediately. Simultaneously fetch from network and update cache.

Use for: Non-critical assets where freshness matters but speed matters more β€” avatars, non-blocking scripts, CSS.

import { StaleWhileRevalidate } from 'workbox-strategies';

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/feed'),
  new StaleWhileRevalidate({
    cacheName: 'api-feed-v1',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 3600 }),
    ],
  })
);

4. NetworkOnly

Always fetch from network, never cache. Falls through to browser default.

Use for: Analytics pings, payment APIs, anything where caching is dangerous.

import { NetworkOnly } from 'workbox-strategies';

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/checkout'),
  new NetworkOnly()
);

5. CacheOnly

Only serve from cache, never network. Fails if not cached.

Use for: Rarely used alone. Useful when you’ve pre-cached everything needed for offline and want strict cache enforcement.

import { CacheOnly } from 'workbox-strategies';

registerRoute(
  ({ url }) => url.pathname.startsWith('/offline-assets/'),
  new CacheOnly({ cacheName: 'offline-essential-v1' })
);

Strategy Decision Matrix

Resource TypeStrategyWhy
HTML pagesNetworkFirstFresh by default, fallback for offline
Versioned JS/CSSCacheFirstImmutable assets (content-hashed)
Google FontsCacheFirst + ExpirationPluginRarely change, large bandwidth
API data (feed)StaleWhileRevalidateSpeed + eventual freshness
API data (critical)NetworkFirstMust be current
User avatar imagesStaleWhileRevalidateGood enough if slightly stale
Payment/auth APIsNetworkOnlyNever cache sensitive ops

Precaching with Workbox

Precaching downloads and caches assets at install time, before any user navigates to them. It’s the foundation of reliable offline experiences.

Vite Integration

npm install -D vite-plugin-pwa
// vite.config.js
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
      manifest: {
        name: 'My App',
        short_name: 'MyApp',
        description: 'My Progressive Web App',
        theme_color: '#ffffff',
        icons: [
          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 100, maxAgeSeconds: 3600 },
            },
          },
        ],
      },
    }),
  ],
});

Webpack Integration

npm install -D workbox-webpack-plugin
// webpack.config.js
const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = {
  plugins: [
    new InjectManifest({
      swSrc: './src/sw.js',     // Your Service Worker source
      swDest: 'sw.js',          // Output name
      maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB limit
    }),
  ],
};
// src/sw.js
import { precacheAndRoute } from 'workbox-precaching';

// __WB_MANIFEST is replaced by Webpack with the asset manifest
precacheAndRoute(self.__WB_MANIFEST);

The build tool injects the asset manifest with content hashes into your SW at build time. On deploy, only changed files get re-downloaded.


Web App Manifest

The manifest controls how your app looks when installed. Here’s a production-ready example:

{
  "name": "DevPlaybook",
  "short_name": "DevPlaybook",
  "description": "Developer tools and guides",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "any",
  "theme_color": "#1a1a2e",
  "background_color": "#ffffff",
  "lang": "en",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide",
      "label": "Desktop view"
    }
  ],
  "shortcuts": [
    {
      "name": "JSON Formatter",
      "url": "/tools/json-formatter",
      "icons": [{ "src": "/icons/shortcut-json.png", "sizes": "96x96" }]
    }
  ]
}

Key fields:

  • start_url β€” add ?source=pwa for analytics
  • display: standalone β€” hides browser UI (required for install prompt)
  • purpose: maskable β€” for adaptive icons on Android (use Maskable.app to generate)
  • shortcuts β€” home screen quick-actions (Android/Windows)
  • screenshots β€” shown in install dialogs on some platforms

Offline-First Patterns

Offline Fallback Page

Show a custom page when navigation fails and there’s nothing cached:

// sw.js
import { setCatchHandler, setDefaultHandler } from 'workbox-routing';
import { NetworkOnly, NetworkFirst } from 'workbox-strategies';
import { precacheAndRoute } from 'workbox-precaching';

precacheAndRoute([
  { url: '/offline.html', revision: '1' },
  ...self.__WB_MANIFEST,
]);

// Default: NetworkFirst for navigation
setDefaultHandler(new NetworkFirst());

// Catch-all for navigation failures
setCatchHandler(async ({ event }) => {
  if (event.request.destination === 'document') {
    return caches.match('/offline.html');
  }
  return Response.error();
});

Background Sync for Form Submissions

Queue failed POST requests and retry when back online:

import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

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

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

When the user submits a form offline, it queues to IndexedDB. When connectivity returns, the browser fires the sync event and Workbox replays the request automatically.

Broadcast Cache Update

Notify the page when a cached resource has been updated in the background:

// sw.js
import { StaleWhileRevalidate } from 'workbox-strategies';
import { BroadcastUpdatePlugin } from 'workbox-broadcast-update';
import { registerRoute } from 'workbox-routing';

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    plugins: [new BroadcastUpdatePlugin()],
  })
);
// main.js β€” listen for updates
const channel = new BroadcastChannel('workbox');
channel.addEventListener('message', (event) => {
  if (event.data.type === 'CACHE_UPDATED') {
    showToast('New content available β€” reload to update');
  }
});

Push Notifications

Push notifications require a push subscription, a server with VAPID keys, and a Service Worker to receive pushes.

Step 1: Generate VAPID Keys

npx web-push generate-vapid-keys

Step 2: Subscribe the User

// main.js
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // Send subscription to your server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' },
  });
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}

Step 3: Handle Push in Service Worker

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: 'Update', body: 'New content' };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url || '/')
  );
});

Workbox with React / Next.js

next-pwa (Next.js)

npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  register: true,
  skipWaiting: true,
  disable: process.env.NODE_ENV === 'development',
  runtimeCaching: require('./cache.config.js'),
});

module.exports = withPWA({ /* next config */ });
// cache.config.js
module.exports = [
  {
    urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
    handler: 'CacheFirst',
    options: {
      cacheName: 'google-fonts-stylesheets',
      expiration: { maxEntries: 4, maxAgeSeconds: 365 * 24 * 60 * 60 },
    },
  },
  // ... more rules
];

Update Prompt with workbox-window

// app.js
import { Workbox } from 'workbox-window';

const wb = new Workbox('/sw.js');

wb.addEventListener('waiting', () => {
  // New SW waiting β€” prompt user
  if (confirm('New version available! Update now?')) {
    wb.messageSkipWaiting();
    wb.addEventListener('controlling', () => window.location.reload());
  }
});

wb.register();

Testing Your PWA

Lighthouse Audit

npx lighthouse https://yourapp.com --view --preset pwa

A passing PWA audit requires:

  • βœ… Registers a Service Worker
  • βœ… Works offline (200 response when offline)
  • βœ… Installable (manifest + icons + SW)
  • βœ… Uses HTTPS

DevTools

  1. Chrome DevTools β†’ Application tab β†’ Service Workers
  2. Check β€œOffline” checkbox to simulate network failure
  3. Cache Storage to inspect what’s cached
  4. Manifest to validate your manifest.json

Common Issues

IssueFix
SW not updatingCheck skipWaiting + clients.claim()
Assets not cachedCheck globPatterns in Workbox config
Install prompt not showingVerify manifest has all required fields
Push not workingCheck VAPID keys match + subscription is fresh
Offline page not showingEnsure /offline.html is precached

Production Checklist

  • HTTPS configured (or localhost for dev)
  • manifest.json has name, icons (192+512), start_url, display: standalone
  • Maskable icon variant generated (Maskable.app)
  • Service Worker registered after load event
  • Precaching configured for critical assets
  • Per-resource caching strategies tuned (see matrix above)
  • Offline fallback page precached and wired up
  • Background sync for POST requests
  • Update prompt for new SW versions
  • Lighthouse PWA score β‰₯ 90
  • Tested on real Android device (install prompt behavior differs by OS)

Summary

PWAs in 2026 are a mature technology with clear implementation patterns. Workbox removes the tedious parts β€” cache busting, precache manifest generation, expiration policies β€” and lets you focus on which strategy fits each resource type.

The key decisions:

  1. Precache your app shell (HTML, CSS, JS) for instant loads
  2. NetworkFirst for navigations (fresh content, offline fallback)
  3. CacheFirst for versioned static assets (they never change)
  4. StaleWhileRevalidate for content where speed beats freshness
  5. Background Sync for form submissions and mutations

Start with vite-plugin-pwa or next-pwa, get a Lighthouse 100, then layer in push notifications and background sync as your users demand it. The install prompt and offline experience alone will improve retention metrics β€” users who install a PWA visit twice as often as browser-only users.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.