Progressive Web Apps (PWA)

Advanced Caching with Workbox

18 min Lesson 12 of 30

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:
  1. 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
  2. Add a BroadcastUpdatePlugin to notify when API data updates
  3. Create a custom plugin that adds a custom header to all cached responses
  4. Implement cache versioning and cleanup old caches on activation
  5. 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.