Skip to main content

Guide

Workbox vs vite-pwa vs next-pwa 2026

Compare Workbox, vite-pwa, and next-pwa for adding Progressive Web App features and service workers. Caching strategies, offline support, push notifications.

·PkgPulse Team·
0

TL;DR

Workbox (from Google) is the foundational service worker toolkit — provides caching strategies, background sync, precaching, and push notification helpers. It's the layer that vite-pwa and next-pwa build on top of. vite-pwa is the zero-config PWA plugin for Vite (and SvelteKit, Nuxt, Astro, SolidStart) — generates service workers using Workbox with minimal setup. next-pwa wraps Workbox specifically for Next.js with automatic route precaching. In 2026: pick the wrapper for your framework (vite-pwa for Vite projects, next-pwa for Next.js), reach for raw Workbox only when you need custom service worker logic beyond what the wrappers provide.

Key Takeaways

  • workbox: ~5M weekly downloads — Google's service worker toolkit, all caching strategies
  • vite-pwa (vite-plugin-pwa): ~2M weekly downloads — zero-config PWA for Vite ecosystem
  • next-pwa: ~300K weekly downloads — Workbox wrapper for Next.js
  • Service workers intercept network requests — use them for offline support, caching, and push notifications
  • Workbox strategies: CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly, CacheOnly
  • PWA requirements: HTTPS + service worker + Web App Manifest → "Add to Home Screen" prompts

Service Worker Fundamentals

Service Worker lifecycle:
  1. register()  — browser downloads and parses the SW file
  2. install     — SW caches precached assets (AppShell)
  3. activate    — old SW replaced, new SW takes control
  4. fetch       — SW intercepts all network requests

Caching strategies:
  CacheFirst       — serve from cache, fetch in background if cache miss
  NetworkFirst     — try network, fall back to cache (freshest data)
  StaleWhileRevalidate — serve cached immediately, update cache in background
  NetworkOnly      — always fetch (for dynamic APIs)
  CacheOnly        — cache only (offline-first assets)

Use cases:
  Static assets (JS, CSS, images)  → CacheFirst (hash in filename = safe)
  HTML pages                        → NetworkFirst (ensure latest layout)
  API responses                     → StaleWhileRevalidate (fast + fresh)
  Real-time data (prices, chat)     → NetworkOnly (no stale data)

Workbox

Workbox — Google's service worker libraries:

Service worker setup (manual Workbox)

// service-worker.js — imported by your app, runs in SW context

import { precacheAndRoute } from "workbox-precaching"
import { registerRoute, NavigationRoute } from "workbox-routing"
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
} from "workbox-strategies"
import { ExpirationPlugin } from "workbox-expiration"
import { BackgroundSyncPlugin } from "workbox-background-sync"

// Precache assets (injected by Workbox build tools during build):
precacheAndRoute(self.__WB_MANIFEST)
// Workbox injects the manifest at build time:
// [{ url: "/app.abc123.js", revision: null }, { url: "/index.html", revision: "def456" }]

// Cache images with CacheFirst + expiration:
registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({
    cacheName: "images-cache",
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,          // Max 100 images in cache
        maxAgeSeconds: 30 * 24 * 60 * 60,  // 30 days
        purgeOnQuotaError: true,
      }),
    ],
  })
)

// Cache API responses with StaleWhileRevalidate:
registerRoute(
  ({ url }) => url.pathname.startsWith("/api/packages"),
  new StaleWhileRevalidate({
    cacheName: "api-packages-cache",
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 5 * 60,  // 5 minutes
      }),
    ],
  })
)

// NetworkFirst for HTML navigation (always try to get latest page):
registerRoute(
  new NavigationRoute(
    new NetworkFirst({
      cacheName: "pages-cache",
      networkTimeoutSeconds: 3,  // Fall back to cache after 3s timeout
    })
  )
)

Background sync

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

// Retry failed POST requests when back online:
const bgSyncPlugin = new BackgroundSyncPlugin("analytics-queue", {
  maxRetentionTime: 24 * 60,  // Retry for up to 24 hours (in minutes)
})

registerRoute(
  ({ url }) => url.pathname === "/api/analytics",
  new NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  "POST"
)
// If the POST fails (offline), it's stored and retried when connectivity returns

Register from the app

// src/registerSW.ts:
import { Workbox } from "workbox-window"

const wb = new Workbox("/service-worker.js")

// Show update notification:
wb.addEventListener("waiting", () => {
  if (confirm("New version available. Reload?")) {
    wb.messageSkipWaiting()
    wb.addEventListener("controlling", () => window.location.reload())
  }
})

wb.register()

vite-pwa (vite-plugin-pwa)

vite-pwa — zero-config PWA for Vite, SvelteKit, Nuxt, Astro:

Vite setup

// 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",  // Auto-update SW without prompt

      // Web App Manifest:
      manifest: {
        name: "PkgPulse",
        short_name: "PkgPulse",
        description: "npm package health scores and comparisons",
        theme_color: "#0a0a0a",
        background_color: "#0a0a0a",
        display: "standalone",
        scope: "/",
        start_url: "/",
        icons: [
          { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
          { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
          { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
        ],
      },

      // Workbox configuration:
      workbox: {
        // Precache all static assets (Vite generates hashed filenames):
        globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],

        // Runtime caching strategies:
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.pkgpulse\.com\/packages\/.*/i,
            handler: "StaleWhileRevalidate",
            options: {
              cacheName: "api-packages-cache",
              expiration: { maxEntries: 100, maxAgeSeconds: 5 * 60 },
            },
          },
          {
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
            handler: "CacheFirst",
            options: {
              cacheName: "images-cache",
              expiration: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 },
            },
          },
        ],
      },
    }),
  ],
})

Prompt for update

// vite.config.ts — use "prompt" instead of "autoUpdate":
VitePWA({
  registerType: "prompt",

  // ... manifest and workbox config
})
// src/UpdatePrompt.tsx — show update notification:
import { useRegisterSW } from "virtual:pwa-register/react"

export function UpdatePrompt() {
  const {
    needRefresh: [needRefresh, setNeedRefresh],
    updateServiceWorker,
  } = useRegisterSW({
    onRegistered(r) {
      console.log("SW Registered:", r)
    },
    onRegisterError(error) {
      console.log("SW registration error", error)
    },
  })

  if (!needRefresh) return null

  return (
    <div className="update-prompt">
      <span>New content available, click on reload button to update.</span>
      <button onClick={() => updateServiceWorker(true)}>Reload</button>
      <button onClick={() => setNeedRefresh(false)}>Close</button>
    </div>
  )
}

SvelteKit integration

// vite.config.ts (SvelteKit):
import { sveltekit } from "@sveltejs/kit/vite"
import { VitePWA } from "vite-plugin-pwa"

export default {
  plugins: [
    sveltekit(),
    VitePWA({
      srcDir: "src",
      filename: "service-worker.ts",
      strategies: "injectManifest",  // Use custom SW file
      manifest: { /* ... */ },
    }),
  ],
}

next-pwa

@ducanh2912/next-pwa — PWA for Next.js:

Setup

// next.config.js:
const withPWA = require("@ducanh2912/next-pwa").default({
  dest: "public",                  // Service worker output directory
  cacheOnFrontEndNav: true,        // Cache pages on client-side navigation
  aggressiveFrontEndNavCaching: true,
  reloadOnOnline: true,            // Reload when back online after offline
  swcMinify: true,
  disable: process.env.NODE_ENV === "development",  // No SW in dev

  workboxOptions: {
    disableDevLogs: true,
    // Custom runtime caching:
    runtimeCaching: [
      {
        urlPattern: /^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,
        handler: "CacheFirst",
        options: {
          cacheName: "google-fonts-webfonts",
          expiration: {
            maxEntries: 4,
            maxAgeSeconds: 365 * 24 * 60 * 60,  // 1 year
          },
        },
      },
      {
        urlPattern: /\/api\/packages\/.*/i,
        handler: "NetworkFirst",
        options: {
          cacheName: "api-packages",
          networkTimeoutSeconds: 10,
          expiration: { maxEntries: 32, maxAgeSeconds: 24 * 60 * 60 },
        },
      },
    ],
  },
})

module.exports = withPWA({
  // Your existing next.config.js:
  reactStrictMode: true,
})

Web App Manifest for Next.js

// public/manifest.json:
{
  "name": "PkgPulse",
  "short_name": "PkgPulse",
  "description": "npm package health scores",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a0a",
  "theme_color": "#0a0a0a",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}
// app/layout.tsx — link manifest:
import type { Metadata } from "next"

export const metadata: Metadata = {
  manifest: "/manifest.json",
  themeColor: "#0a0a0a",
  appleWebApp: {
    capable: true,
    statusBarStyle: "black-translucent",
    title: "PkgPulse",
  },
}

Feature Comparison

FeatureWorkboxvite-pwanext-pwa
FrameworkNone (universal)Vite / SvelteKit / Nuxt / AstroNext.js
ConfigManualZero-configMinimal
Workbox strategies✅ All✅ Via config✅ Via workboxOptions
Background sync
Custom SW file✅ injectManifest
Auto manifest gen❌ (manual)
Update promptsManual✅ React/Vue hooksManual
Weekly downloads~5M~2M~300K
TypeScript

When to Use Each

Choose vite-pwa if:

  • Using Vite, SvelteKit, Nuxt 3+, or Astro
  • Want zero-config setup with Workbox under the hood
  • Need framework-specific update hooks (useRegisterSW)

Choose next-pwa if:

  • Using Next.js (App Router or Pages Router)
  • Want automatic Next.js route precaching
  • Minimal config with sensible defaults

Use raw Workbox directly if:

  • Non-Vite, non-Next.js projects (Webpack, Parcel)
  • Need fine-grained control over the service worker
  • Building a custom service worker with complex routing logic
  • Working on a library or component that ships its own service worker

Skip PWA/service workers if:

  • Server-side only app (no meaningful offline state)
  • Auth-heavy apps where stale data is a security risk
  • App is always live with no valuable offline experience

Service Worker Lifecycle and Cache Invalidation

The most common production PWA problem is stale content: users who have the old service worker running see outdated JavaScript, CSS, or data long after you've deployed an update. Understanding the update lifecycle is critical. When you deploy a new build, the new service worker installs alongside the old one but enters a "waiting" state — it cannot take control until all tabs using the old service worker are closed. The skipWaiting() call forces the new service worker to take control immediately, but this can cause issues if different tabs are running different app versions simultaneously. The recommended production pattern is showing an update prompt to the user, letting them save their work, and then triggering skipWaiting() manually. Workbox's clientsClaim() function combined with skipWaiting() activates the new service worker and immediately takes control of all existing clients, ensuring consistent behavior across tabs.

TypeScript Integration in Service Workers

Service workers run in a different global context than your main application and require specific TypeScript configuration. The service worker context has access to self, caches, fetch, and clients but not window, document, or localStorage. Your tsconfig needs separate settings for service worker files: set "lib": ["WebWorker", "ESNext"] in a dedicated tsconfig for service worker files to get correct type checking without false errors from window-only APIs. Workbox's TypeScript types ship with the package and work well in this context. vite-pwa generates service worker files automatically from your configuration, so you rarely write raw service worker TypeScript — but the injectManifest strategy lets you write a custom service worker file with full TypeScript support, using Workbox's typed APIs throughout.

Offline-First Design Considerations

A well-designed PWA needs to make deliberate decisions about which content is valuable offline and which is not. Static application shells (HTML, CSS, core JavaScript) should always be cached with CacheFirst since they're versioned by hash. Dynamic content (user data, API responses) requires more nuance: StaleWhileRevalidate works well for content that changes occasionally and where slightly stale data is acceptable, while NetworkFirst suits content where freshness is critical. For authenticated APIs, avoid caching responses that contain sensitive user data — configure Workbox routing to exclude auth-gated endpoints from the cache, or ensure cache responses respect authentication state. The Cross-Origin response header affects cacheability: API responses without Access-Control-Allow-Origin headers cannot be cached by the service worker even if your code attempts to do so.

Performance Impact of PWA Features

Adding a service worker improves perceived performance through asset precaching but can also introduce measurable overhead if misconfigured. The initial service worker registration and precache download adds network requests on first load — this is intentional, but precaching too many assets hurts the first-visit experience. A practical limit is precaching only the critical path assets (core HTML, CSS, main JavaScript bundle) and letting runtime caching handle images and secondary scripts lazily. The Web App Manifest's display: standalone mode removes browser chrome and gives your app a native-app feel, which users associate with performance even if load times are identical. Push notification support requires separate permission handling and a push service worker setup that is orthogonal to caching — do not conflate the two when planning your PWA implementation.

Self-Hosting vs Cloud PWA Deployment

Service workers are subject to same-origin policy: the service worker file must be served from the same origin as your application. This means self-hosted deployments (custom domains, Docker containers) work identically to cloud deployments — there's no special consideration for Vercel vs a VPS. The one infrastructure consideration is the service worker scope: the service worker at /sw.js controls all pages under /, while a service worker at /app/sw.js only controls pages under /app/. Place your service worker at the root path for the broadest scope. For CDN-distributed assets, configure CORS headers correctly so the service worker can cache cross-origin responses — without Access-Control-Allow-Origin: * or a matching origin on CDN responses, the service worker intercepts the request but cannot cache the response body. Lightning CSS and Vite's build pipeline correctly scope asset URLs for CDN hosting when configured with the base option.

Debugging Service Workers in Development

Service worker debugging requires understanding the browser devtools Surface. Chrome's Application tab shows registered service workers, their state (installing, waiting, active), cached assets, and cache storage contents. The most common development pain point is stale service workers blocking updates — use "Update on reload" in Chrome DevTools or open a fresh incognito window to get a clean service worker state. Workbox's development build (used automatically by vite-pwa in development mode) adds verbose console logging showing which caching strategy handled each request. vite-pwa disables service worker registration in development mode by default since development workflows require uncached network requests — enable it only when specifically testing PWA behavior. The workbox-window package's browser-side helpers provide programmatic access to service worker state changes, making it possible to show meaningful UI when an update is waiting rather than silently applying updates that confuse users.

Compare frontend tooling and build packages on PkgPulse →

See also: Vite vs webpack and Turbopack vs Vite, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.