Caching Strategies with Workbox
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.
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)?
| Strategy | Behavior | Best For |
|---|---|---|
| CacheFirst | Check cache first. Network only if cache misses. | Fonts, images, static assets that rarely change |
| NetworkFirst | Try network first. Fall back to cache if offline. | API responses, frequently updated HTML pages |
| StaleWhileRevalidate | Return cache immediately. Fetch update in background. | Semi-dynamic content: avatars, non-critical API data |
| NetworkOnly | Always go to network. No caching. | Analytics pings, non-GET requests |
| CacheOnly | Only 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.
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.
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.
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 do | What 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 |
- 1CacheFirst for immutable assets (hashed files, fonts), NetworkFirst for dynamic content (HTML, API), StaleWhileRevalidate for semi-dynamic (avatars, non-critical data)
- 2Precache only your app shell — runtime cache everything else on-demand
- 3Always set networkTimeoutSeconds on NetworkFirst to handle slow connections gracefully
- 4Use BackgroundSyncPlugin for offline form submissions and mutations
- 5Set purgeOnQuotaError: true on growing caches to prevent storage eviction