Browser APIsFeatured
JavaScript Service Workers: Complete Offline-First Guide
Master Service Workers in JavaScript for offline functionality, caching strategies, and Progressive Web Apps. Build resilient web applications.
By JavaScriptDoc Team•
service-workerspwaofflinecachingjavascript
JavaScript Service Workers: Complete Offline-First Guide
Service Workers are powerful scripts that run in the background, enabling offline functionality, push notifications, and advanced caching strategies for Progressive Web Apps.
Understanding Service Workers
Service Workers act as a proxy between your web app and the network, allowing you to intercept requests and serve cached responses.
// Check Service Worker support
if ('serviceWorker' in navigator) {
console.log('Service Workers supported');
// Register service worker
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration);
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
});
}
// Service Worker lifecycle
// 1. Download
// 2. Install
// 3. Activate
// 4. Fetch/Message events
// Basic service worker (sw.js)
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/main.js',
'/offline.html',
];
// Install event - cache resources
self.addEventListener('install', (event) => {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
// Activate event - cleanup old caches
self.addEventListener('activate', (event) => {
console.log('Service Worker activating...');
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
})
);
});
// Fetch event - serve from cache
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Cache hit - return response
if (response) {
return response;
}
return fetch(event.request);
})
);
});
Registration and Updates
Service Worker Registration
// Advanced registration with options
class ServiceWorkerManager {
constructor() {
this.registration = null;
this.updateFound = false;
}
async register() {
if (!('serviceWorker' in navigator)) {
console.log('Service Workers not supported');
return;
}
try {
// Register with scope
this.registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('Service Worker registered:', this.registration.scope);
// Check registration state
if (this.registration.installing) {
console.log('Service Worker installing');
} else if (this.registration.waiting) {
console.log('Service Worker waiting');
} else if (this.registration.active) {
console.log('Service Worker active');
}
// Listen for updates
this.registration.addEventListener('updatefound', () => {
this.handleUpdateFound();
});
// Check for updates periodically
setInterval(() => {
this.registration.update();
}, 60000); // Check every minute
} catch (error) {
console.error('Service Worker registration failed:', error);
}
}
handleUpdateFound() {
const newWorker = this.registration.installing;
this.updateFound = true;
newWorker.addEventListener('statechange', () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
// New update available
this.notifyUserAboutUpdate();
}
});
}
notifyUserAboutUpdate() {
if (confirm('New version available! Reload to update?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
async unregister() {
if (this.registration) {
const success = await this.registration.unregister();
console.log('Service Worker unregistered:', success);
}
}
}
// Handle skip waiting in service worker
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Claim clients after activation
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
Caching Strategies
Common Caching Patterns
// 1. Cache First (Cache Falling Back to Network)
self.addEventListener('fetch', (event) => {
event.respondWith(
caches
.match(event.request)
.then((response) => response || fetch(event.request))
);
});
// 2. Network First (Network Falling Back to Cache)
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});
// 3. Cache Then Network
// In main app
async function getData() {
const cache = await caches.open('data-cache');
const cachedResponse = await cache.match('/api/data');
if (cachedResponse) {
displayData(await cachedResponse.json());
}
try {
const networkResponse = await fetch('/api/data');
cache.put('/api/data', networkResponse.clone());
displayData(await networkResponse.json());
} catch (error) {
if (!cachedResponse) {
showError('No cached data available');
}
}
}
// 4. Stale While Revalidate
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 response || fetchPromise;
});
})
);
});
// 5. Network Only
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/realtime')) {
event.respondWith(fetch(event.request));
}
});
// 6. Cache Only
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/static/')) {
event.respondWith(caches.match(event.request));
}
});
Advanced Caching Implementation
// Sophisticated caching strategy
class CacheStrategy {
constructor() {
this.cacheNames = {
static: 'static-v1',
dynamic: 'dynamic-v1',
images: 'images-v1',
api: 'api-cache-v1',
};
}
// Determine strategy based on request
async handleFetch(request) {
const url = new URL(request.url);
// Static assets - Cache First
if (this.isStaticAsset(url)) {
return this.cacheFirst(request, this.cacheNames.static);
}
// API calls - Network First with timeout
if (url.pathname.startsWith('/api/')) {
return this.networkFirstWithTimeout(request, 3000, this.cacheNames.api);
}
// Images - Stale While Revalidate
if (request.destination === 'image') {
return this.staleWhileRevalidate(request, this.cacheNames.images);
}
// Default - Network First
return this.networkFirst(request, this.cacheNames.dynamic);
}
isStaticAsset(url) {
const staticExtensions = ['.js', '.css', '.woff', '.woff2', '.ttf'];
return staticExtensions.some((ext) => url.pathname.endsWith(ext));
}
async cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
return this.offlineFallback();
}
}
async networkFirst(request, cacheName) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await caches.match(request);
return cached || this.offlineFallback();
}
}
async networkFirstWithTimeout(request, timeout, cacheName) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(request, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
clearTimeout(timeoutId);
const cached = await caches.match(request);
return cached || this.offlineFallback();
}
}
async staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
return cached || fetchPromise;
}
async offlineFallback() {
return (
caches.match('/offline.html') ||
new Response('Offline', {
status: 503,
statusText: 'Service Unavailable',
headers: new Headers({
'Content-Type': 'text/plain',
}),
})
);
}
}
// Use in service worker
const cacheStrategy = new CacheStrategy();
self.addEventListener('fetch', (event) => {
event.respondWith(cacheStrategy.handleFetch(event.request));
});
Background Sync
Offline Queue Management
// Background sync for offline actions
class OfflineQueue {
constructor() {
this.dbName = 'offline-queue';
this.storeName = 'requests';
}
async openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, {
keyPath: 'id',
autoIncrement: true,
});
}
};
});
}
async addRequest(request) {
const db = await this.openDB();
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
const serialized = {
url: request.url,
method: request.method,
headers: [...request.headers],
body: await request.text(),
timestamp: Date.now(),
};
return store.add(serialized);
}
async getAll() {
const db = await this.openDB();
const tx = db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async remove(id) {
const db = await this.openDB();
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
return store.delete(id);
}
}
// In service worker
const queue = new OfflineQueue();
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
event.respondWith(
fetch(event.request.clone()).catch(async () => {
// Queue the request for later
await queue.addRequest(event.request);
// Register for background sync
await self.registration.sync.register('sync-queue');
// Return success response
return new Response(JSON.stringify({ queued: true }), {
headers: { 'Content-Type': 'application/json' },
});
})
);
}
});
// Background sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-queue') {
event.waitUntil(syncQueue());
}
});
async function syncQueue() {
const requests = await queue.getAll();
for (const req of requests) {
try {
const request = new Request(req.url, {
method: req.method,
headers: req.headers,
body: req.body,
});
const response = await fetch(request);
if (response.ok) {
await queue.remove(req.id);
}
} catch (error) {
console.error('Sync failed for request:', req.id);
// Will retry on next sync
}
}
}
Push Notifications
Implementation Guide
// Push notification setup
class PushNotificationManager {
constructor() {
this.swRegistration = null;
}
async init() {
// Check support
if (!('Notification' in window)) {
console.log('Notifications not supported');
return;
}
// Request permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
return;
}
// Get service worker registration
this.swRegistration = await navigator.serviceWorker.ready;
// Subscribe to push
await this.subscribeToPush();
}
async subscribeToPush() {
try {
// Check existing subscription
let subscription =
await this.swRegistration.pushManager.getSubscription();
if (!subscription) {
// Subscribe with public key
const publicKey = 'YOUR_VAPID_PUBLIC_KEY';
const convertedKey = this.urlBase64ToUint8Array(publicKey);
subscription = await this.swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedKey,
});
// Send subscription to server
await this.sendSubscriptionToServer(subscription);
}
console.log('Push subscription:', subscription);
} catch (error) {
console.error('Failed to subscribe to push:', error);
}
}
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async sendSubscriptionToServer(subscription) {
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
});
if (!response.ok) {
throw new Error('Failed to send subscription to server');
}
}
async sendNotification(title, options = {}) {
if (this.swRegistration.showNotification) {
await this.swRegistration.showNotification(title, {
body: options.body || '',
icon: options.icon || '/icon-192.png',
badge: options.badge || '/badge-72.png',
image: options.image,
tag: options.tag || 'default',
requireInteraction: options.requireInteraction || false,
data: options.data || {},
actions: options.actions || [],
vibrate: options.vibrate || [200, 100, 200],
});
}
}
}
// In service worker - handle push events
self.addEventListener('push', (event) => {
console.log('Push received:', event);
let notification;
if (event.data) {
try {
notification = event.data.json();
} catch (e) {
notification = {
title: 'New Message',
body: event.data.text(),
};
}
} else {
notification = {
title: 'New Notification',
body: 'You have a new notification',
};
}
const options = {
body: notification.body,
icon: notification.icon || '/icon-192.png',
badge: '/badge-72.png',
data: notification.data || {},
actions: notification.actions || [
{ action: 'view', title: 'View' },
{ action: 'dismiss', title: 'Dismiss' },
],
};
event.waitUntil(
self.registration.showNotification(notification.title, options)
);
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event);
event.notification.close();
if (event.action === 'view') {
// Open specific URL
event.waitUntil(clients.openWindow(event.notification.data.url || '/'));
} else if (event.action === 'dismiss') {
// Just close
} else {
// Default action - focus or open app
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
}
});
Cache Management
Advanced Cache Control
// Cache versioning and cleanup
class CacheManager {
constructor() {
this.version = 'v1';
this.caches = {
static: `static-${this.version}`,
dynamic: `dynamic-${this.version}`,
images: `images-${this.version}`,
};
this.maxAge = {
static: 30 * 24 * 60 * 60 * 1000, // 30 days
dynamic: 24 * 60 * 60 * 1000, // 1 day
images: 7 * 24 * 60 * 60 * 1000, // 7 days
};
this.maxItems = {
dynamic: 50,
images: 100,
};
}
async precache(urls) {
const cache = await caches.open(this.caches.static);
// Add all with progress
let completed = 0;
const total = urls.length;
for (const url of urls) {
try {
await cache.add(url);
completed++;
// Notify progress
await this.notifyClients('cache-progress', {
completed,
total,
percentage: Math.round((completed / total) * 100),
});
} catch (error) {
console.error(`Failed to cache ${url}:`, error);
}
}
}
async cleanupCaches() {
const cacheNames = await caches.keys();
const currentCaches = Object.values(this.caches);
const promises = cacheNames.map((cacheName) => {
if (!currentCaches.includes(cacheName)) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
});
await Promise.all(promises);
}
async trimCache(cacheName, maxItems) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
if (requests.length > maxItems) {
// Delete oldest entries
const toDelete = requests.slice(0, requests.length - maxItems);
for (const request of toDelete) {
await cache.delete(request);
}
}
}
async deleteExpiredEntries(cacheName, maxAge) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
const now = Date.now();
for (const request of requests) {
const response = await cache.match(request);
const dateHeader = response.headers.get('date');
if (dateHeader) {
const date = new Date(dateHeader).getTime();
if (now - date > maxAge) {
await cache.delete(request);
}
}
}
}
async getCacheSize() {
if ('storage' in navigator && 'estimate' in navigator.storage) {
const estimate = await navigator.storage.estimate();
return {
usage: estimate.usage,
quota: estimate.quota,
percentage: Math.round((estimate.usage / estimate.quota) * 100),
};
}
return null;
}
async notifyClients(type, data) {
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
type,
data,
});
});
}
}
// Periodic cache cleanup
self.addEventListener('message', (event) => {
if (event.data.type === 'cleanup-caches') {
const manager = new CacheManager();
event.waitUntil(
(async () => {
await manager.cleanupCaches();
// Trim dynamic caches
await manager.trimCache(
manager.caches.dynamic,
manager.maxItems.dynamic
);
await manager.trimCache(manager.caches.images, manager.maxItems.images);
// Delete expired entries
for (const [cacheType, cacheName] of Object.entries(manager.caches)) {
if (manager.maxAge[cacheType]) {
await manager.deleteExpiredEntries(
cacheName,
manager.maxAge[cacheType]
);
}
}
// Report cache size
const size = await manager.getCacheSize();
await manager.notifyClients('cache-size', size);
})()
);
}
});
Communication Patterns
Client-Service Worker Communication
// In main application
class ServiceWorkerClient {
constructor() {
this.messageHandlers = new Map();
this.pendingMessages = new Map();
this.messageId = 0;
// Listen for messages from service worker
navigator.serviceWorker.addEventListener('message', (event) => {
this.handleMessage(event.data);
});
}
on(type, handler) {
if (!this.messageHandlers.has(type)) {
this.messageHandlers.set(type, []);
}
this.messageHandlers.get(type).push(handler);
}
handleMessage(message) {
// Handle response to sent message
if (message.id && this.pendingMessages.has(message.id)) {
const { resolve, reject } = this.pendingMessages.get(message.id);
this.pendingMessages.delete(message.id);
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message.data);
}
return;
}
// Handle broadcast messages
const handlers = this.messageHandlers.get(message.type) || [];
handlers.forEach((handler) => handler(message.data));
}
async send(type, data) {
const controller = await navigator.serviceWorker.controller;
if (!controller) {
throw new Error('No service worker controller');
}
const id = ++this.messageId;
return new Promise((resolve, reject) => {
this.pendingMessages.set(id, { resolve, reject });
controller.postMessage({
id,
type,
data,
});
// Timeout after 5 seconds
setTimeout(() => {
if (this.pendingMessages.has(id)) {
this.pendingMessages.delete(id);
reject(new Error('Message timeout'));
}
}, 5000);
});
}
broadcast(type, data) {
navigator.serviceWorker.controller?.postMessage({
type,
data,
});
}
}
// In service worker
class ServiceWorkerServer {
constructor() {
this.messageHandlers = new Map();
}
on(type, handler) {
this.messageHandlers.set(type, handler);
}
async handleMessage(event) {
const { id, type, data } = event.data;
const handler = this.messageHandlers.get(type);
if (!handler) {
if (id) {
event.ports[0].postMessage({
id,
error: `Unknown message type: ${type}`,
});
}
return;
}
try {
const result = await handler(data, event);
if (id) {
event.ports[0].postMessage({
id,
data: result,
});
}
} catch (error) {
if (id) {
event.ports[0].postMessage({
id,
error: error.message,
});
}
}
}
async broadcast(type, data) {
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
type,
data,
});
});
}
}
// Usage example
const swServer = new ServiceWorkerServer();
// Register handlers
swServer.on('get-cache-size', async () => {
const manager = new CacheManager();
return await manager.getCacheSize();
});
swServer.on('clear-cache', async (data) => {
const cacheName = data.cacheName;
if (cacheName) {
await caches.delete(cacheName);
} else {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
}
return { success: true };
});
self.addEventListener('message', (event) => {
swServer.handleMessage(event);
});
Best Practices
-
Progressive Enhancement
// Check for support before using if ('serviceWorker' in navigator) { // Register service worker }
-
Version Management
// Use versioned cache names const CACHE_VERSION = 'v1.2.3'; const CACHE_NAME = `app-${CACHE_VERSION}`;
-
Selective Caching
// Don't cache everything if (request.method === 'GET' && !request.url.includes('/api/')) { // Cache static resources only }
-
Error Handling
// Always provide fallbacks event.respondWith( fetch(event.request).catch(() => caches.match('/offline.html')) );
Conclusion
Service Workers enable powerful offline experiences:
- Offline functionality with intelligent caching
- Background sync for resilient data submission
- Push notifications for user engagement
- Performance optimization through caching
- Progressive Web App capabilities
- Network resilience and reliability
Key takeaways:
- Implement appropriate caching strategies
- Handle updates gracefully
- Provide offline fallbacks
- Manage cache size and cleanup
- Test offline scenarios thoroughly
- Monitor performance and errors
Master Service Workers to build truly progressive web applications!