Skip to content

Caching Strategies with Workbox

advanced16 min read

Why Not Write Caching Logic by Hand?

You can write service worker caching logic from scratch. We did exactly that in the previous topic. But in production, you'll rediscover every edge case that Google's team already solved: cache versioning, cache expiration, opaque response handling, precache manifests with revision hashes, broadcast updates, quota management.

Workbox is Google's open-source library for service worker caching. It's not a framework — it's a set of modules you compose. Use the strategies you need, skip the rest. Every major PWA in production uses Workbox or something built on top of it.

Mental Model

Workbox is a strategy playbook for your service worker. Instead of writing if (cached) return cached; else fetch() by hand (and forgetting edge cases), you pick a named strategy — CacheFirst, NetworkFirst, StaleWhileRevalidate — and Workbox handles the caching logic, error handling, fallbacks, and expiration for you. Think of it as Express middleware, but for your cache.

The Five Strategies

Every caching decision boils down to one question: when a request comes in, do you prefer speed (cache) or freshness (network)?

StrategyBehaviorBest For
CacheFirstCheck cache first. Network only if cache misses.Fonts, images, static assets that rarely change
NetworkFirstTry network first. Fall back to cache if offline.API responses, frequently updated HTML pages
StaleWhileRevalidateReturn cache immediately. Fetch update in background.Semi-dynamic content: avatars, non-critical API data
NetworkOnlyAlways go to network. No caching.Analytics pings, non-GET requests
CacheOnlyOnly serve from cache. Never touch network.Precached assets you know are in the cache

CacheFirst

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

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

The request hits the cache first. If found, return it immediately — zero network latency. If not cached, fetch from network, cache the response, and return it.

Best for assets that don't change once deployed: hashed static files, fonts, images with content-addressable URLs.

NetworkFirst

import { NetworkFirst } from 'workbox-strategies';

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-responses',
    networkTimeoutSeconds: 3,
    plugins: [
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  })
);

Try the network first. If it responds, cache the result and return it. If the network fails (offline, timeout), fall back to the cached version.

networkTimeoutSeconds is critical: if the network is slow (not offline, just slow), the user waits. Setting a timeout of 3 seconds means "if the network hasn't responded in 3 seconds, serve the cache." This prevents the worst user experience — staring at a spinner on a flaky connection.

Quiz
You are caching API responses with NetworkFirst and networkTimeoutSeconds: 3. The user is on a slow 2G connection. What happens?

StaleWhileRevalidate

import { StaleWhileRevalidate } from 'workbox-strategies';

registerRoute(
  ({ url }) => url.pathname.startsWith('/content/'),
  new StaleWhileRevalidate({
    cacheName: 'content',
    plugins: [
      new ExpirationPlugin({ maxEntries: 200 }),
    ],
  })
);

Return the cached response immediately (fast!), but simultaneously fetch a fresh copy from the network and update the cache. The user sees the cached version now and gets the updated version on their next visit.

This is the best of both worlds for content that updates occasionally but where showing a slightly stale version is acceptable. User avatars, blog posts, product descriptions.

Quiz
With StaleWhileRevalidate, when does the user see the latest version of a resource?

Precaching

Precaching downloads and caches a list of URLs during the service worker's install event. This is your offline shell — the critical assets that make your app work without a network.

import { precacheAndRoute } from 'workbox-precaching';

precacheAndRoute([
  { url: '/index.html', revision: 'abc123' },
  { url: '/styles/main.css', revision: 'def456' },
  { url: '/scripts/app.js', revision: 'ghi789' },
  '/images/logo.svg',
]);

Each entry has a URL and a revision hash. When you deploy new code, the revision changes. Workbox sees the new revision, fetches the updated file, and caches it. Entries without revisions are treated as immutable (useful for hashed filenames like app.a1b2c3.js).

Build-Time Manifest Generation

You don't write the precache manifest by hand. Workbox's build tools generate it:

// workbox-config.js
module.exports = {
  globDirectory: 'dist/',
  globPatterns: ['**/*.{html,js,css,svg,png,woff2}'],
  swDest: 'dist/sw.js',
  swSrc: 'src/sw.js',
};

Run workbox injectManifest workbox-config.js and it replaces a placeholder in your service worker with the actual manifest, including revision hashes computed from file contents.

Routing

Workbox routing matches incoming requests to strategies:

import { registerRoute, NavigationRoute, Route } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({ cacheName: 'images' })
);

registerRoute(
  ({ url }) => url.origin === 'https://api.example.com',
  new NetworkFirst({ cacheName: 'api' })
);

const navigationRoute = new NavigationRoute(
  new NetworkFirst({ cacheName: 'pages' }),
  {
    allowlist: [/^\/app\//],
    denylist: [/^\/api\//],
  }
);

registerRoute(navigationRoute);

NavigationRoute specifically handles navigation requests (page loads). The allowlist/denylist controls which URLs are treated as navigations.

Plugins

Workbox plugins hook into the request lifecycle:

ExpirationPlugin

new ExpirationPlugin({
  maxEntries: 60,
  maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
  purgeOnQuotaError: true,
})

Limits cache size by entry count or age. purgeOnQuotaError automatically cleans the cache if the browser's storage quota is exceeded — essential for preventing your app from breaking when storage fills up.

CacheableResponsePlugin

import { CacheableResponsePlugin } from 'workbox-cacheable-response';

new CacheableResponsePlugin({
  statuses: [0, 200],
})

Only caches responses with specific status codes. Status 0 is for opaque responses (cross-origin without CORS) — caching those is risky but sometimes necessary for third-party resources.

BackgroundSyncPlugin

import { BackgroundSyncPlugin } from 'workbox-background-sync';

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

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

When a POST request fails (user is offline), BackgroundSync stores the request in IndexedDB and replays it when connectivity returns. The browser's Background Sync API wakes the service worker even if the tab is closed.

Quiz
A user submits a form while offline. You are using Workbox BackgroundSyncPlugin. What happens?

Workbox Build Tools

Workbox offers three ways to integrate with your build:

1. workbox-cli

npx workbox-cli wizard
npx workbox injectManifest workbox-config.js

The wizard generates a config file. injectManifest takes your handwritten service worker and injects the precache manifest.

2. workbox-webpack-plugin

const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = {
  plugins: [
    new InjectManifest({
      swSrc: './src/sw.js',
      swDest: 'sw.js',
    }),
  ],
};

3. workbox-build (Node API)

const { injectManifest } = require('workbox-build');

await injectManifest({
  swSrc: 'src/sw.js',
  swDest: 'dist/sw.js',
  globDirectory: 'dist',
  globPatterns: ['**/*.{html,js,css}'],
});

All three approaches do the same thing: scan your build output, compute revision hashes, and inject the manifest into your service worker.

Putting It All Together

A production service worker combining precaching, runtime caching, and background sync:

import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

precacheAndRoute(self.__WB_MANIFEST);

const navigationHandler = new NetworkFirst({
  cacheName: 'pages',
  networkTimeoutSeconds: 3,
});
registerRoute(new NavigationRoute(navigationHandler));

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

registerRoute(
  ({ url }) => url.origin === 'https://fonts.googleapis.com',
  new StaleWhileRevalidate({ cacheName: 'google-fonts-stylesheets' })
);

registerRoute(
  ({ url }) => url.origin === 'https://fonts.gstatic.com',
  new CacheFirst({
    cacheName: 'google-fonts-webfonts',
    plugins: [
      new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }),
    ],
  })
);
What developers doWhat they should do
Using CacheFirst for HTML pages
CacheFirst returns the cached version forever (until it expires or is evicted). For HTML pages, this means users never see content updates. HTML is the entry point — it should always be as fresh as possible.
Use NetworkFirst or StaleWhileRevalidate for HTML so users get fresh content
Precaching everything in your build output
Precaching downloads all listed files during install. If you list 200 images, the user downloads all 200 on first visit — even if they never view them. Precache the minimum for offline functionality; let runtime caching handle the rest on-demand.
Only precache the app shell (HTML, critical CSS/JS). Use runtime caching for images, fonts, and API data.
Forgetting purgeOnQuotaError in ExpirationPlugin
When browser storage fills up, the browser may evict your entire origin's storage (including all caches, IndexedDB, etc). purgeOnQuotaError proactively deletes the cache before the browser reaches that point, preventing catastrophic data loss.
Always set purgeOnQuotaError: true on caches that can grow unbounded
Key Rules
  1. 1CacheFirst for immutable assets (hashed files, fonts), NetworkFirst for dynamic content (HTML, API), StaleWhileRevalidate for semi-dynamic (avatars, non-critical data)
  2. 2Precache only your app shell — runtime cache everything else on-demand
  3. 3Always set networkTimeoutSeconds on NetworkFirst to handle slow connections gracefully
  4. 4Use BackgroundSyncPlugin for offline form submissions and mutations
  5. 5Set purgeOnQuotaError: true on growing caches to prevent storage eviction