Introduction to Service Workers
Service workers act as a programmable network proxy between your web application and the network. They are a type of web worker that runs in the background, separate from the main browser thread, and can intercept network requests, cache resources, and enable offline functionality for web applications.
Think of a service worker as a diligent personal assistant who stands between you and the outside world. This assistant can:
- Intercept your outgoing messages (network requests)
- Store important documents (cache resources)
- Deliver previously saved information when you're disconnected (offline functionality)
- Work independently while you focus on other tasks (background processing)
Service Worker Lifecycle
Service workers have a distinct lifecycle that's important to understand when implementing them:
Registration
The first step is to register a service worker. This tells the browser where your service worker JavaScript file is located:
// Check if service workers are supported
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
});
}
Installation
The installation event is triggered when the service worker is first discovered and installed. This is typically where you'll cache static assets:
const CACHE_NAME = 'my-site-cache-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/main.js',
'/images/logo.png'
];
self.addEventListener('install', event => {
// Perform install steps
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
Activation
The activation event is fired when the service worker starts controlling the page. This is a good time to clean up old caches:
self.addEventListener('activate', event => {
const cacheWhitelist = ['my-site-cache-v1'];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
// If this cache name isn't in the whitelist, delete it
return caches.delete(cacheName);
}
})
);
})
);
});
Fetch Event
Once activated, the service worker can intercept fetch events. This is where you implement your caching strategy:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - return response
if (response) {
return response;
}
// Clone the request because it's a one-time use stream
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then(response => {
// Check if we received a valid response
if(!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response because it's a one-time use stream
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});
Caching Strategies
There are several caching strategies you can implement with service workers:
Cache First (Offline First)
Check the cache first, and only go to the network if the resource isn't cached. Perfect for static assets that don't change often.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request);
})
);
});
Network First
Try the network first, and fall back to the cache if the network request fails. Ideal for content that needs to be up-to-date but should still work offline.
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
// Clone the response to store in cache
let responseClone = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
});
Stale While Revalidate
Return cached version immediately (if available), then update the cache with a fresh network response for next time. Great for balancing speed and freshness.
self.addEventListener('fetch', event => {
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(response => {
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Return the cache response if we have one, otherwise wait for the network response
return response || fetchPromise;
});
})
);
});
Cache Only
Only use the cache, never go to network. Useful for assets that are guaranteed to be cached during installation.
self.addEventListener('fetch', event => {
event.respondWith(caches.match(event.request));
});
Network Only
Only use the network, never check the cache. Good for non-GET requests or requests that should always be fresh.
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request));
});
Real-World Implementation Examples
E-commerce PWA
For an e-commerce site, you might use different strategies for different types of requests:
- Cache First: for product images, CSS, JavaScript
- Network First: for product details that may change
- Network Only: for checkout process and cart operations
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Static assets: Cache First
if (url.pathname.startsWith('/images/') ||
url.pathname.startsWith('/styles/') ||
url.pathname.startsWith('/scripts/')) {
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request)
.then(response => {
let responseClone = response.clone();
caches.open('static-assets')
.then(cache => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
}
// Product details: Network First
else if (url.pathname.startsWith('/products/')) {
event.respondWith(
fetch(event.request)
.then(response => {
let responseClone = response.clone();
caches.open('product-cache')
.then(cache => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(event.request);
})
);
}
// Checkout: Network Only
else if (url.pathname.startsWith('/checkout/') || url.pathname.startsWith('/cart/')) {
event.respondWith(fetch(event.request));
}
// Default: Stale While Revalidate
else {
event.respondWith(
caches.open('default-cache').then(cache => {
return cache.match(event.request).then(response => {
const fetchPromise = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
})
);
}
});
Background Sync
Service workers can also perform background synchronization, allowing failed requests to be retried when the user regains connectivity.
Example: Message Sending in a Chat App
When a user sends a message while offline:
// In your web app
function sendMessage(message) {
if (!navigator.onLine) {
// Save the message to IndexedDB
saveMessageToIndexedDB(message)
.then(() => {
// Register a sync event
return navigator.serviceWorker.ready;
})
.then(registration => {
return registration.sync.register('outbox-sync');
})
.catch(err => console.error('Background sync registration failed:', err));
return;
}
// Otherwise, send as normal
return fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
headers: {
'Content-Type': 'application/json'
}
});
}
// In your service worker
self.addEventListener('sync', event => {
if (event.tag === 'outbox-sync') {
event.waitUntil(
// Get all messages from IndexedDB
getMessagesFromIndexedDB()
.then(messages => {
// Send each message
return Promise.all(
messages.map(message => {
return fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message),
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
// If successful, remove from IndexedDB
return removeMessageFromIndexedDB(message.id);
}
throw new Error('Failed to send message');
});
})
);
})
);
}
});
Push Notifications
Service workers can receive push events and display notifications, even when the user isn't actively using your site.
// Request permission
function requestNotificationPermission() {
return Notification.requestPermission().then(permission => {
if (permission === 'granted') {
subscribeUserToPush();
}
});
}
// Subscribe the user to push notifications
function subscribeUserToPush() {
navigator.serviceWorker.ready
.then(registration => {
const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY')
};
return registration.pushManager.subscribe(subscribeOptions);
})
.then(pushSubscription => {
// Send the subscription to your server
return fetch('/api/subscriptions', {
method: 'POST',
body: JSON.stringify(pushSubscription),
headers: {
'Content-Type': 'application/json'
}
});
});
}
// In your service worker
self.addEventListener('push', event => {
if (event.data) {
const data = event.data.json();
const options = {
body: data.body,
icon: '/images/icon.png',
badge: '/images/badge.png',
data: {
url: data.url
}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
}
});
// Handle notification clicks
self.addEventListener('notificationclick', event => {
event.notification.close();
if (event.notification.data && event.notification.data.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
Common Challenges and Solutions
Updating Service Workers
Service workers persist until they're explicitly updated. One common pattern is to include a version number in your cache name:
const CACHE_VERSION = 'v2';
const CACHE_NAME = `my-site-cache-${CACHE_VERSION}`;
// Then in the activate event, delete old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName.startsWith('my-site-cache-') && cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
Debugging Service Workers
Service workers can be challenging to debug. Chrome DevTools provides dedicated tools for service worker debugging:
- Chrome DevTools > Application tab > Service Workers
- Use "Update on reload" during development
- Use "Bypass for network" to temporarily disable service workers
Handling Cross-Origin Requests
By default, service workers can only intercept requests to their own origin. For cross-origin requests, you need CORS support:
// Add mode: 'cors' to fetch requests
fetch('https://api.example.com/data', { mode: 'cors' })
.then(response => {
// Handle response
});
Best Practices
- Progressive Enhancement: Always build your app to work without service workers first, then enhance with offline capability
- Cache Responsibly: Don't cache everything; be strategic about what you cache and for how long
- Handle Errors Gracefully: Always have fallbacks for when both cache and network fail
- Update Strategy: Have a clear plan for how to update your service worker and cached content
- Security Considerations: Be careful with what you cache, especially for authenticated content
- Testing: Test your service worker in multiple browsers and network conditions, including offline
Practice Activities
Activity 1: Basic Service Worker Implementation
Create a simple web page with HTML, CSS, and JavaScript, then add a service worker that caches the static assets and enables offline access.
Activity 2: Multiple Caching Strategies
Modify your service worker to use different caching strategies for different types of content (e.g., cache-first for static resources, network-first for API requests).
Activity 3: Background Sync
Create a form that saves submissions to IndexedDB when offline and uses background sync to send them when connectivity is restored.
Activity 4: Service Worker Update Process
Implement a versioning system for your service worker and create a user interface that notifies users when a new version is available and prompts them to refresh.