Deep Dive into Workbox Strategies
In this lesson, we'll explore advanced caching techniques using Workbox plugins and custom strategies to build robust, high-performance PWAs.
Understanding Strategy Parameters
All Workbox strategies accept an options object with common parameters:
new workbox.strategies.CacheFirst({
// Cache name for this strategy
cacheName: 'my-cache',
// Array of plugins to use
plugins: [
// Plugins go here
],
// Additional fetch options
fetchOptions: {
mode: 'cors',
credentials: 'include'
},
// Additional cache options
matchOptions: {
ignoreSearch: true,
ignoreVary: true
}
});
Cache Expiration Plugin
The ExpirationPlugin automatically removes cached entries based on age or number of entries:
workbox.routing.registerRoute(
({ request }) => request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.ExpirationPlugin({
// Maximum number of entries to cache
maxEntries: 50,
// Maximum age of entries in seconds (30 days)
maxAgeSeconds: 30 * 24 * 60 * 60,
// Automatically cleanup when quota is exceeded
purgeOnQuotaError: true
})
]
})
);
Note: The expiration check happens when the cache is accessed, not continuously in the background. Old entries are removed when new requests trigger the strategy.
Cacheable Response Plugin
This plugin ensures only successful responses are cached:
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new workbox.strategies.NetworkFirst({
cacheName: 'api-cache',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
// Cache responses with these status codes
statuses: [0, 200],
// Cache responses with these headers
headers: {
'x-custom-header': 'cacheable'
}
})
]
})
);
Status code 0 is used for opaque responses (cross-origin requests without CORS):
// Example: Cache Google Fonts (opaque responses)
workbox.routing.registerRoute(
({ url }) => url.origin === 'https://fonts.gstatic.com',
new workbox.strategies.CacheFirst({
cacheName: 'google-fonts',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200] // Include 0 for opaque responses
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
})
]
})
);
Broadcast Update Plugin
The BroadcastUpdatePlugin notifies your app when cached data is updated:
// In your service worker
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/posts/'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'posts-cache',
plugins: [
new workbox.broadcastUpdate.BroadcastUpdatePlugin({
// Channel name for postMessage
channelName: 'api-updates',
// Options for comparing responses
headersToCheck: ['content-length', 'etag', 'last-modified']
})
]
})
);
// In your page JavaScript
const updateChannel = new BroadcastChannel('api-updates');
updateChannel.addEventListener('message', (event) => {
console.log('Cache updated:', event.data);
const { cacheName, updatedURL } = event.data.payload;
// Show notification to user
showUpdateNotification('New content available. Refresh to see updates.');
// Or automatically refresh the content
fetchAndUpdateContent(updatedURL);
});
Tip: Use BroadcastUpdatePlugin with StaleWhileRevalidate to show users a notification when fresh content is available, allowing them to reload without forcing it.
Background Sync Plugin
The BackgroundSyncPlugin queues failed requests and retries them when the network is available:
// Queue POST requests that fail
workbox.routing.registerRoute(
({ url, request }) =>
url.pathname.startsWith('/api/') &&
request.method === 'POST',
new workbox.strategies.NetworkOnly({
plugins: [
new workbox.backgroundSync.BackgroundSyncPlugin('api-queue', {
maxRetentionTime: 24 * 60 // Retry for up to 24 hours
})
]
})
);
// Listen for successful sync
self.addEventListener('sync', (event) => {
if (event.tag === 'api-queue') {
console.log('Background sync completed');
}
});
Range Request Plugin
The RangeRequestsPlugin enables partial content caching for media files:
workbox.routing.registerRoute(
({ request }) => request.destination === 'video',
new workbox.strategies.CacheFirst({
cacheName: 'video-cache',
plugins: [
new workbox.rangeRequests.RangeRequestsPlugin(),
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200, 206] // 206 = Partial Content
})
]
})
);
Note: Range requests are crucial for video and audio files, allowing users to seek to different positions without downloading the entire file.
Custom Plugins
You can create custom plugins to extend Workbox functionality:
// Custom plugin that logs cache operations
const loggingPlugin = {
// Called before a cached response is used
cachedResponseWillBeUsed: async ({ cacheName, request, cachedResponse }) => {
console.log(`Using cached response from ${cacheName}`, request.url);
return cachedResponse;
},
// Called before a response is cached
cacheWillUpdate: async ({ response, request }) => {
console.log(`Caching new response for ${request.url}`);
// Only cache successful responses
if (response.status === 200) {
return response;
}
return null; // Don't cache
},
// Called before a fetch request
requestWillFetch: async ({ request }) => {
console.log(`Fetching: ${request.url}`);
return request;
},
// Called after a fetch completes
fetchDidSucceed: async ({ response, request }) => {
console.log(`Fetch successful: ${request.url}`);
return response;
},
// Called when fetch fails
fetchDidFail: async ({ request, error }) => {
console.error(`Fetch failed for ${request.url}:`, error);
}
};
// Use the custom plugin
workbox.routing.registerRoute(
({ request }) => request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'images',
plugins: [loggingPlugin]
})
);
Creating Custom Strategies
While Workbox provides five built-in strategies, you can create custom ones:
// Custom strategy: Try cache, then network, then fallback
class CacheNetworkFallback {
constructor(options = {}) {
this.cacheName = options.cacheName || 'fallback-cache';
this.fallbackURL = options.fallbackURL;
}
async handle({ request }) {
// Try cache first
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('Serving from cache');
return cachedResponse;
}
try {
// Try network
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(this.cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Return fallback
console.log('Serving fallback');
return caches.match(this.fallbackURL);
}
}
}
// Use the custom strategy
workbox.routing.registerRoute(
({ request }) => request.destination === 'document',
new CacheNetworkFallback({
cacheName: 'pages',
fallbackURL: '/offline.html'
})
);
Advanced Routing Patterns
Multiple Strategies for Same Route
// Use different strategies based on request properties
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
({ url, request }) => {
// Use NetworkOnly for mutations
if (request.method !== 'GET') {
return new workbox.strategies.NetworkOnly().handle({ request });
}
// Use NetworkFirst for critical data
if (url.pathname.includes('/critical/')) {
return new workbox.strategies.NetworkFirst({
networkTimeoutSeconds: 3
}).handle({ request });
}
// Use StaleWhileRevalidate for everything else
return new workbox.strategies.StaleWhileRevalidate({
cacheName: 'api-cache'
}).handle({ request });
}
);
Conditional Caching
// Only cache images from your domain
workbox.routing.registerRoute(
({ url, request }) => {
return request.destination === 'image' &&
url.origin === location.origin;
},
new workbox.strategies.CacheFirst({
cacheName: 'local-images'
})
);
// Cache third-party images differently
workbox.routing.registerRoute(
({ url, request }) => {
return request.destination === 'image' &&
url.origin !== location.origin;
},
new workbox.strategies.CacheFirst({
cacheName: 'external-images',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days
})
]
})
);
Cache Versioning
Implement cache versioning to handle updates cleanly:
const CACHE_VERSION = 'v2';
// Set cache name details with version
workbox.core.setCacheNameDetails({
prefix: 'my-app',
suffix: CACHE_VERSION,
precache: 'precache',
runtime: 'runtime'
});
// Clean up old caches on activation
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// Delete caches that don't match current version
if (!cacheName.includes(CACHE_VERSION)) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
Warm Cache Strategy
Pre-warm the cache with critical resources before they're requested:
// Warm cache on service worker installation
self.addEventListener('install', (event) => {
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/hero.jpg'
];
event.waitUntil(
caches.open('warm-cache-v1').then((cache) => {
return cache.addAll(urlsToCache);
})
);
});
// Later, use the warm cache
workbox.routing.registerRoute(
({ url }) => [
'/',
'/styles/main.css',
'/scripts/app.js'
].includes(url.pathname),
new workbox.strategies.CacheFirst({
cacheName: 'warm-cache-v1'
})
);
Exercise:
- Create a caching strategy for API calls that:
- Uses NetworkFirst with a 3-second timeout
- Only caches successful responses (200 status)
- Expires entries after 24 hours
- Limits the cache to 50 entries
- Add a BroadcastUpdatePlugin to notify when API data updates
- Create a custom plugin that adds a custom header to all cached responses
- Implement cache versioning and cleanup old caches on activation
- Test your implementation by going offline and checking the cache behavior
Performance Monitoring
// Track cache hit rates
let cacheHits = 0;
let cacheMisses = 0;
const performancePlugin = {
cachedResponseWillBeUsed: async ({ cachedResponse }) => {
if (cachedResponse) {
cacheHits++;
} else {
cacheMisses++;
}
// Log stats every 50 requests
if ((cacheHits + cacheMisses) % 50 === 0) {
const hitRate = (cacheHits / (cacheHits + cacheMisses) * 100).toFixed(2);
console.log(`Cache hit rate: ${hitRate}%`);
}
return cachedResponse;
}
};
Warning: Be careful with cache sizes and expiration times. Too aggressive caching can lead to stale data, while too conservative caching defeats the purpose of offline functionality. Monitor your cache usage and adjust strategies based on real-world usage patterns.