1. What is a Service Worker?
A service worker is a script that your browser runs in the background, separate from a web page, opening the door to features that don't need a web page or user interaction. Key characteristics include:
- JavaScript Worker: It's a type of web worker, meaning it runs on a separate thread from the main browser UI, so it can perform tasks without blocking the user interface.
- Proxy Server: It acts as a programmable network proxy, allowing you to intercept and control how network requests from your application are handled.
- Event-driven: It is activated by events, such as network requests or push notifications, and is terminated when not in use to save resources.
- Secure: Service workers only run over HTTPS to prevent man-in-the-middle attacks.
2. The Service Worker Lifecycle
Understanding the service worker lifecycle is crucial for effective development. It consists of three main phases: registration, installation, and activation. A service worker moves through these states, ensuring that a new version can be installed without disrupting the currently active one.
Lifecycle Phases Explained
- 1. Registration
- This is the initial step, triggered by `navigator.serviceWorker.register()` in your main application's JavaScript. This tells the browser where your service worker file lives and kicks off the installation process in the background. The browser downloads, parses, and executes the script.
- 2. Installation
- The `install` event is the first event a service worker receives, and it only runs once per version of the worker. This is the ideal time to prepare your service worker for use, primarily by caching the static assets your application needs to run offline (the "app shell"). By wrapping this work in `event.waitUntil()`, you tell the browser to wait until your caching is complete before considering the worker installed. If any of the promises passed to `waitUntil()` reject, the installation fails, and the service worker is discarded.
- 3. Activation
- After a successful installation, the service worker moves to the `installed` state and waits to become active. The `activate` event fires when the old service worker is gone and the new one can take control. This is the perfect place to manage old caches and clean up resources from previous versions. A new service worker won't control open pages until they are reloaded or until you use `clients.claim()` to take control immediately. This waiting step ensures that only one version of your service worker is running at a time, preventing conflicts.
- 4. Redundant
- A service worker becomes redundant if it fails during installation or if it's replaced by a newer version. It is no longer used.
Registration
The first step is to register the service worker in your main JavaScript file. This code checks if the browser supports service workers and, if so, registers the `service-worker.js` file.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}
Installation
Once registered, the `install` event is fired in the service worker file. This is the perfect time to cache static assets. The `event.waitUntil()` method takes a promise to know how long installation takes, and if it was successful.
// service-worker.js
const STATIC_CACHE_NAME = 'static-assets-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js'
];
self.addEventListener('install', event => {
// Perform install steps
event.waitUntil(
caches.open(STATIC_CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
Activation
After installation, the service worker enters the `activating` state. The `activate` event is a good place to manage old caches, ensuring your service worker cleans up caches that are no longer needed.
// service-worker.js
self.addEventListener('activate', event => {
const cacheWhitelist = [STATIC_CACHE_NAME]; // Add other cache names here
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});
3. Caching Strategies with the Cache API
The Cache API is a system for storing and retrieving network requests and their corresponding responses. It's a key part of creating offline-first web apps. Choosing the right strategy depends on the type of resource you are handling.
Common Caching Patterns
- Cache First: Great for static assets that don't change often.
- Network First: Best for requests where having the most up-to-date data is essential.
- Stale-While-Revalidate: A good balance. Provides a fast response from the cache while updating it in the background for next time.
- Cache Only: For when you only ever want to get things from the cache.
- Network Only: For requests that must not be cached, like non-GET requests.
Cache First
Ideal for static assets. The service worker first checks the cache, and if the resource is not there, it fetches it from the network.
// Cache First Strategy
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
Network First
For resources that need to be up-to-date. The service worker tries the network first and falls back to the cache only if the network request fails.
// Network First Strategy
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(networkResponse => {
// If successful, clone it and cache it.
const responseClone = networkResponse.clone();
caches.open(DYNAMIC_CACHE_NAME).then(cache => {
cache.put(event.request, responseClone);
});
return networkResponse;
})
.catch(() => {
// If network fails, try to serve from cache.
return caches.match(event.request);
})
);
});
Stale-While-Revalidate
This strategy serves content from the cache immediately for a fast response, then checks the network for an update and caches it for the next time the resource is requested.
// Stale-While-Revalidate Strategy
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
const networkFetch = fetch(event.request).then(networkResponse => {
const cacheName = DYNAMIC_CACHE_NAME;
const responseClone = networkResponse.clone();
caches.open(cacheName).then(cache => {
cache.put(event.request, responseClone);
});
return networkResponse;
});
// Return cached response immediately, and the network fetch promise
// will update the cache in the background.
return cachedResponse || networkFetch;
})
);
});
Managing Multiple Caches and Expiration
The Cache API does not have a built-in mechanism for resource expiration. You must manage it yourself. A common and effective strategy is to use multiple, versioned caches for different types of content (e.g., static assets, images, API data). By changing the version string in your cache names, the old cache will be automatically deleted during the `activate` event of the new service worker.
// service-worker.js
const STATIC_CACHE_VERSION = 'v1';
const IMAGE_CACHE_VERSION = 'v1';
const DYNAMIC_CACHE_VERSION = 'v1';
const STATIC_CACHE_NAME = `static-assets-${STATIC_CACHE_VERSION}`;
const IMAGE_CACHE_NAME = `images-${IMAGE_CACHE_VERSION}`;
const DYNAMIC_CACHE_NAME = `dynamic-api-${DYNAMIC_CACHE_VERSION}`;
const CURRENT_CACHES = [
STATIC_CACHE_NAME,
IMAGE_CACHE_NAME,
DYNAMIC_CACHE_NAME
];
// In the 'activate' event, you clean up old caches:
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!CURRENT_CACHES.includes(cacheName)) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
4. Intercepting Requests with the Fetch API
The `fetch` event is fired for every request made by the page. This allows you to intercept the request and apply different caching strategies based on the request type.
self.addEventListener('fetch', event => {
// Example: Apply different strategy for images
if (event.request.destination === 'image') {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request).then(fetchResponse => {
return caches.open(IMAGE_CACHE_NAME).then(cache => {
cache.put(event.request, fetchResponse.clone());
return fetchResponse;
});
});
})
);
}
// Add other strategies here...
});
5. Push Notifications with the Push API
The Push API allows you to send push notifications to a user even when they are not actively using your website. The process involves subscribing the user, sending the subscription to your server, and then handling the `push` event in the service worker to display a notification.
// service-worker.js
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : { title: 'New Message', body: 'You have a new message.' };
const title = data.title;
const options = {
body: data.body,
icon: 'images/icon.png',
badge: 'images/badge.png'
};
event.waitUntil(self.registration.showNotification(title, options));
});
6. Background Sync & Periodic Background Sync
Server Requirement
For both Background Sync and Periodic Background Sync to be useful, you need a server that you control. The service worker needs an endpoint to send data to (for Background Sync) or fetch data from (for Periodic Background Sync). This is almost always your own application's backend server.
Background Sync Example
This is useful for deferring actions, like sending a message in a chat app, until the user has a connection. The process involves registering a sync event from your app and then handling it in the service worker.
1. Registering the Sync (in `main.js`)
// This would typically happen after a `fetch` request fails because the user is offline.
function queueMessageForSync() {
navigator.serviceWorker.ready.then(registration => {
if (registration.sync) {
return registration.sync.register('send-queued-messages');
}
}).then(() => {
console.log('Sync event registered for sending messages.');
}).catch(err => {
console.error('Background sync registration failed:', err);
});
}
// Example of trying to send a message
fetch('/api/messages', { method: 'POST', body: JSON.stringify(newMessage) })
.catch(err => {
// If the fetch fails, we assume it's an offline issue
console.log('Fetch failed; queuing message for background sync.');
// In a real app, you would save the message to IndexedDB first
// saveMessageToOutbox(newMessage);
queueMessageForSync();
});
2. Handling the Sync (in `service-worker.js`)
self.addEventListener('sync', event => {
if (event.tag === 'send-queued-messages') {
event.waitUntil(
// This function would get data from IndexedDB and send it
sendQueuedMessagesToServer()
);
}
});
async function sendQueuedMessagesToServer() {
// This is a simplified example. In a real app, you'd use IndexedDB.
// const messages = getMessagesFromOutbox(); // Function to get data from IndexedDB
// for (const message of messages) {
try {
const response = await fetch('/api/messages', {
method: 'POST',
// body: JSON.stringify(message)
});
if (response.ok) {
console.log('Queued message sent successfully.');
// Remove the message from the outbox after sending
// removeMessageFromOutbox(message.id);
}
} catch (error) {
console.error('Failed to send queued message:', error);
// If it fails again, it will be retried later.
throw error;
}
// }
}
Periodic Background Sync Example
This is useful for pre-fetching content, like the latest news articles, so the app is up-to-date when the user opens it.
1. Registering the Periodic Sync (in `main.js`)
async function registerPeriodicNewsSync() {
const registration = await navigator.serviceWorker.ready;
try {
await registration.periodicSync.register('get-latest-news', {
// An interval of 1 day
minInterval: 24 * 60 * 60 * 1000,
});
console.log('Periodic news sync registered.');
} catch (err) {
console.error('Periodic background sync registration failed:', err);
}
}
// You might call this after the user grants notification permissions or opts-in
registerPeriodicNewsSync();
2. Handling the Periodic Sync (in `service-worker.js`)
self.addEventListener('periodicsync', event => {
if (event.tag === 'get-latest-news') {
event.waitUntil(
fetchLatestNewsAndUpdateCache()
);
}
});
async function fetchLatestNewsAndUpdateCache() {
console.log('Fetching latest news for periodic sync...');
try {
const response = await fetch('/api/latest-news');
const articles = await response.json();
const cache = await caches.open(DYNAMIC_CACHE_NAME);
// Cache the main API response
await cache.put('/api/latest-news', new Response(JSON.stringify(articles)));
// Optionally, cache individual article pages or images
// for (const article of articles) {
// await cache.add(article.url);
// }
console.log('Latest news cached successfully.');
} catch (error) {
console.error('Periodic sync failed:', error);
}
}
7. "Save for Later" Feature
A powerful use case for service workers is allowing users to save individual articles or pages for offline reading. This pattern creates a much richer user experience than simply caching the entire application shell.
Note: This feature is most effective within a **Progressive Web App (PWA)**. While it can work in a standard browser tab, the experience is seamless in a PWA, where the user expects app-like capabilities, including reliable offline access to saved content.
Implementation Steps
The core idea is to create a special request that the service worker can identify. When a user clicks "Save for Later," the app sends this request, and the service worker intercepts it, fetches all necessary assets for that page, and stores them in a dedicated cache.
1. Triggering the Save (in `main.js`)
Add a "Save for Later" button to your posts. The click handler constructs a special URL and uses `fetch` to send it. The service worker will catch this request.
// Assuming you have buttons with a 'data-post-url' attribute
document.querySelectorAll('.save-for-later-btn').forEach(button => {
button.addEventListener('click', event => {
const postUrl = event.target.dataset.postUrl;
// Create a special URL for the service worker to intercept.
// The 'save-for-later' part is a custom flag.
const saveUrl = `${postUrl}?save-for-later=true`;
console.log(`Requesting to save: ${postUrl}`);
// We don't care about the response, just that the SW gets the request.
fetch(saveUrl).then(() => {
alert('Post saved for offline reading!');
}).catch(err => {
console.error('Save request failed:', err);
alert('Could not save post.');
});
});
});
2. Handling the Save (in `service-worker.js`)
In the `fetch` event listener, check for your special URL parameter. If found, prevent the default network request, fetch the page and its assets, and add them to a dedicated "saved articles" cache.
const SAVED_ARTICLES_CACHE_NAME = 'saved-articles-v1';
// Add SAVED_ARTICLES_CACHE_NAME to your CURRENT_CACHES array for cleanup.
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Check if this is a "save for later" request.
if (url.searchParams.has('save-for-later')) {
// Intercept the request and handle the saving logic.
event.respondWith(
savePageForOffline(event.request)
);
return; // Important: stop further processing.
}
// ... other fetch strategies
});
async function savePageForOffline(request) {
const url = new URL(request.url);
// Get the original URL of the page to save.
const pageUrl = url.origin + url.pathname;
try {
const cache = await caches.open(SAVED_ARTICLES_CACHE_NAME);
const response = await fetch(pageUrl);
// In a real app, you'd parse the HTML response to find
// and fetch other assets like CSS, JS, and images.
// For simplicity, we'll just cache the main HTML page here.
await cache.put(pageUrl, response.clone());
console.log(`Successfully cached page: ${pageUrl}`);
// Return a success response to the client.
return new Response(JSON.stringify({ status: 'ok' }), {
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.error(`Failed to save page ${pageUrl}:`, error);
// Return an error response.
return new Response(JSON.stringify({ status: 'failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
To view the saved article, your main `fetch` handler would simply need to check the `SAVED_ARTICLES_CACHE_NAME` first when a user navigates to that article's URL.
8. Complete Service Worker Example
Here is a complete `service-worker.js` file that demonstrates how to use these APIs together. It includes multiple cache management, different fetching strategies, and handlers for push and sync events.
// service-worker.js
// --- Cache Versioning ---
const STATIC_CACHE_VERSION = 'v1.1';
const IMAGE_CACHE_VERSION = 'v1.1';
const DYNAMIC_CACHE_VERSION = 'v1.1';
const SAVED_ARTICLES_CACHE_VERSION = 'v1.1';
const STATIC_CACHE_NAME = `static-assets-${STATIC_CACHE_VERSION}`;
const IMAGE_CACHE_NAME = `images-${IMAGE_CACHE_VERSION}`;
const DYNAMIC_CACHE_NAME = `dynamic-api-${DYNAMIC_CACHE_VERSION}`;
const SAVED_ARTICLES_CACHE_NAME = `saved-articles-${SAVED_ARTICLES_CACHE_VERSION}`;
const CURRENT_CACHES = [
STATIC_CACHE_NAME,
IMAGE_CACHE_NAME,
DYNAMIC_CACHE_NAME,
SAVED_ARTICLES_CACHE_NAME
];
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/main.js',
'/offline.html' // A fallback page for offline access
];
// --- Lifecycle Events ---
// INSTALL: Pre-cache static assets
self.addEventListener('install', event => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE_NAME).then(cache => {
console.log('[Service Worker] Pre-caching static assets');
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting(); // Force the waiting service worker to become the active service worker.
});
// ACTIVATE: Clean up old caches
self.addEventListener('activate', event => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!CURRENT_CACHES.includes(cacheName)) {
console.log('[Service Worker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
return self.clients.claim(); // Become the service worker for all open tabs.
});
// --- Functional Events ---
// FETCH: Apply caching strategies
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Strategy: Save for Later
if (url.searchParams.has('save-for-later')) {
event.respondWith(savePageForOffline(request));
return;
}
// Strategy: Network first for API calls
if (request.url.includes('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
// If successful, cache the response
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE_NAME).then(cache => {
cache.put(request.url, responseClone);
});
return response;
})
.catch(() => {
// If network fails, try the cache
return caches.match(request);
})
);
return;
}
// Strategy: Cache first for images
if (request.destination === 'image') {
event.respondWith(
caches.match(request).then(cachedResponse => {
return cachedResponse || fetch(request).then(networkResponse => {
const responseClone = networkResponse.clone();
caches.open(IMAGE_CACHE_NAME).then(cache => {
cache.put(request, responseClone);
});
return networkResponse;
});
})
);
return;
}
// Strategy: Cache first for everything else (including saved articles)
event.respondWith(
caches.match(request).then(cachedResponse => {
return cachedResponse || fetch(request)
.catch(() => caches.match('/offline.html')); // Fallback for navigation requests
})
);
});
// PUSH: Handle incoming push notifications
self.addEventListener('push', event => {
console.log('[Service Worker] Push Received.');
const data = event.data ? event.data.json() : { title: 'Default Title', body: 'Default body text.'};
const title = data.title;
const options = {
body: data.body,
icon: 'images/icon-96x96.png',
badge: 'images/badge-72x72.png'
};
event.waitUntil(self.registration.showNotification(title, options));
});
// SYNC: Handle background sync events
self.addEventListener('sync', event => {
console.log('[Service Worker] Background sync event:', event.tag);
if (event.tag === 'send-queued-messages') {
event.waitUntil(
// Here you would implement the logic to send queued data to the server
// For example, reading from IndexedDB and sending via fetch()
console.log('Processing background sync for queued messages...')
);
}
});
// PERIODIC SYNC: Handle periodic background sync events
self.addEventListener('periodicsync', event => {
console.log('[Service Worker] Periodic sync event:', event.tag);
if (event.tag === 'get-latest-news') {
event.waitUntil(
// Here you would fetch new content and update the cache
console.log('Fetching latest news for periodic sync...')
);
}
});
9. Browser Support
Browser support for service worker-related APIs is strong in modern browsers, but some of the newer features are still limited. Always check compatibility for your target audience. (Support accurate as of late 2025).
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Service Worker | Yes | Yes | Yes | Yes |
| Cache API | Yes | Yes | Yes | Yes |
| Fetch API | Yes | Yes | Yes | Yes |
| Push API | Yes | Yes | Partial¹ | Yes |
| Background Sync | Yes | No | No | Yes |
| Periodic Background Sync | Yes | No | No | Yes |
¹ Safari supports web push notifications but uses its own Apple Push Notification service (APNs) and does not fully implement the Push API standard.
10. Conclusion
Service workers are a fundamental part of modern web development, enabling a more robust and engaging user experience. By mastering the concepts of the service worker lifecycle, caching, and the associated APIs, you can build web applications that are fast, reliable, and work offline.