JavaScript Service Workers: Offline-First Applications
Service workers are JavaScript files that run in the background, separate from your web page, acting as a programmable proxy between your application and the network. They're the backbone of...
Key Insights
- Service workers act as programmable network proxies that enable offline-first web applications by intercepting and controlling network requests, with a lifecycle independent of your web page
- Choosing the right caching strategy—Cache First for static assets, Network First for API calls, or Stale While Revalidate for frequently updated content—is critical for balancing performance with data freshness
- Service workers require HTTPS in production, operate on a separate thread from your main JavaScript, and need careful version management to avoid serving stale code to users
Introduction to Service Workers and Offline-First Architecture
Service workers are JavaScript files that run in the background, separate from your web page, acting as a programmable proxy between your application and the network. They’re the backbone of Progressive Web Apps (PWAs) and enable features like offline functionality, background sync, and push notifications.
The offline-first approach flips traditional web development on its head. Instead of treating network connectivity as the default and offline as an error state, offline-first architecture assumes the network is unreliable and designs applications to work primarily from cached local data, syncing with servers when possible.
The service worker lifecycle consists of three main phases: registration (initiated from your main JavaScript), installation (where you can pre-cache resources), and activation (where you clean up old caches). Once activated, the service worker can intercept fetch events and control how your application handles network requests.
Let’s start with basic registration:
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered:', registration.scope);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
Setting Up Your First Service Worker
Service worker registration should happen early in your application lifecycle, typically in your main JavaScript file. The scope parameter determines which pages the service worker controls—by default, it’s the directory where the service worker file lives.
Here’s a more robust registration with update handling:
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('SW registered with scope:', registration.scope);
// Check for updates every hour
setInterval(() => {
registration.update();
}, 3600000);
// Listen for new service worker taking over
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
if (confirm('New version available! Reload to update?')) {
window.location.reload();
}
}
});
});
})
.catch(error => {
console.error('SW registration failed:', error);
});
});
}
Now create your service worker file with a basic skeleton:
// sw.js
const CACHE_VERSION = 'v1.0.0';
const CACHE_NAME = `app-cache-${CACHE_VERSION}`;
self.addEventListener('install', event => {
console.log('Service Worker installing...');
self.skipWaiting(); // Activate immediately
});
self.addEventListener('activate', event => {
console.log('Service Worker activating...');
});
self.addEventListener('fetch', event => {
console.log('Fetching:', event.request.url);
});
Caching Strategies for Offline Support
Different resources need different caching strategies. Static assets like CSS and images benefit from Cache First, while API calls often need Network First to ensure fresh data.
Cache First loads from cache if available, falling back to network:
// Cache First Strategy
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
cache.put(request, response.clone());
return response;
}
Network First tries the network, falling back to cache on failure:
// Network First Strategy
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await cache.match(request);
if (cached) {
return cached;
}
throw error;
}
}
Stale While Revalidate returns cached content immediately while fetching fresh data in the background:
// Stale While Revalidate Strategy
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
Implementing the Install and Activate Events
The install event is your opportunity to pre-cache critical assets. This ensures your app can load even when completely offline:
// sw.js
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/logo.svg',
'/offline.html'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Pre-caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
The activate event handles cleanup of old caches when you deploy a new version:
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name.startsWith('app-cache-') && name !== CACHE_NAME)
.map(name => {
console.log('Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
Intercepting Network Requests with Fetch Events
The fetch event is where your caching strategies come together. Route different request types to appropriate strategies:
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Handle API calls with Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Handle images with Cache First
if (request.destination === 'image') {
event.respondWith(cacheFirst(request));
return;
}
// Handle HTML with Network First, fallback to offline page
if (request.destination === 'document') {
event.respondWith(
networkFirst(request).catch(() =>
caches.match('/offline.html')
)
);
return;
}
// Default: Stale While Revalidate
event.respondWith(staleWhileRevalidate(request));
});
Here’s a more sophisticated approach with different handling for different content types:
self.addEventListener('fetch', event => {
const { request } = event;
event.respondWith(
(async () => {
const url = new URL(request.url);
// Skip cross-origin requests
if (url.origin !== location.origin) {
return fetch(request);
}
// API calls: Network First
if (url.pathname.startsWith('/api/')) {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: 'Offline' }), {
headers: { 'Content-Type': 'application/json' },
status: 503
});
}
}
// Static assets: Cache First
return cacheFirst(request);
})()
);
});
Advanced Patterns: Background Sync and Push Notifications
Background Sync allows you to defer actions until the user has connectivity. Perfect for forms and data submission:
// In your app code
async function submitForm(data) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data)
});
} catch (error) {
// Queue for background sync
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('submit-form');
// Store data in IndexedDB
await saveToIndexedDB('pending-submissions', data);
}
}
// In sw.js
self.addEventListener('sync', event => {
if (event.tag === 'submit-form') {
event.waitUntil(
(async () => {
const pending = await getFromIndexedDB('pending-submissions');
for (const data of pending) {
try {
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data)
});
await removeFromIndexedDB('pending-submissions', data.id);
} catch (error) {
console.error('Sync failed:', error);
}
}
})()
);
}
});
Push notifications require user permission and server coordination:
// Request permission and subscribe
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'YOUR_PUBLIC_VAPID_KEY'
});
// Send subscription to your server
await fetch('/api/push-subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
}
// Handle push events in sw.js
self.addEventListener('push', event => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon.png',
badge: '/badge.png',
data: { url: data.url }
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Testing, Debugging, and Best Practices
Chrome DevTools provides excellent service worker debugging. Navigate to Application > Service Workers to inspect, update, or unregister workers. Use the Network panel to verify cache hits.
Implement update detection to notify users of new versions:
// sw.js - Message clients about updates
self.addEventListener('message', event => {
if (event.data === 'skipWaiting') {
self.skipWaiting();
}
});
// main.js - Handle updates gracefully
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
Manage cache size to avoid storage quota issues:
async function cleanupOldCaches(maxAge = 7 * 24 * 60 * 60 * 1000) {
const cache = await caches.open(CACHE_NAME);
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');
const cacheDate = new Date(dateHeader).getTime();
if (now - cacheDate > maxAge) {
await cache.delete(request);
}
}
}
Critical Best Practices:
- Always use HTTPS in production—service workers won’t register on insecure origins (localhost is exempt)
- Version your caches—use cache names with version numbers to force updates
- Keep service workers small—they’re downloaded every time they update
- Handle errors gracefully—always provide fallbacks for offline scenarios
- Test offline thoroughly—use DevTools to simulate offline conditions
- Monitor cache size—implement cleanup strategies to avoid quota exceeded errors
- Update carefully—use
skipWaiting()judiciously to avoid breaking active sessions
Service workers transform web applications from fragile, network-dependent experiences into robust, offline-capable platforms. Start with simple caching strategies, test thoroughly, and gradually add advanced features as your application demands them.