Service Workers and Offline Functionality: Building Resilient Web Applications
Imagine a user browsing your website on a train, and the connection drops. With a traditional web application, they see a blank page or an error message. With Service Workers, they continue browsing cached content seamlessly. When the connection returns, their actions sync automatically.
This is the power of Service Workersโa technology that transforms how we think about web application reliability and performance. Service Workers act as a programmable proxy between your web application and the network, giving you unprecedented control over how your app behaves online and offline.
In this guide, we’ll explore what Service Workers are, how they work, and how to implement them effectively. By the end, you’ll understand how to build web applications that work reliably regardless of network conditions.
What Are Service Workers?
A Service Worker is a JavaScript file that runs in the background, separate from your main web page. Think of it as a middleman between your application and the network. Every request your app makes passes through the Service Worker, which can intercept, modify, or cache it.
Key characteristics:
- Background execution: Runs independently of the web page
- Network proxy: Intercepts all network requests
- Persistent: Stays active even when the page is closed
- Scope-based: Operates within a defined URL scope
- Requires HTTPS: Security requirement (except localhost)
- Asynchronous: Uses Promises and async/await
What Service Workers can do:
- Cache resources for offline access
- Serve cached content when offline
- Perform background sync
- Handle push notifications
- Prefetch resources
- Modify requests and responses
- Implement custom routing logic
What Service Workers cannot do:
- Access the DOM directly
- Use synchronous APIs (localStorage, synchronous XHR)
- Block page rendering
- Access certain browser APIs (geolocation, camera)
Understanding the Service Worker Lifecycle
Service Workers have a distinct lifecycle with four main phases: registration, installation, activation, and fetch handling. Understanding this lifecycle is crucial for implementing them correctly.
Phase 1: Registration
Registration tells the browser about your Service Worker. This happens in your main application code:
// app.js - Main application file
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered successfully:', registration);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
});
}
Why register on page load? Registering after the page loads ensures the page renders quickly. The Service Worker installation happens in the background.
Phase 2: Installation
The installation phase happens after registration. This is where you cache essential assets needed for your app to work offline.
// service-worker.js
const CACHE_NAME = 'my-app-v1';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html',
'/images/logo.png'
];
self.addEventListener('install', event => {
console.log('Service Worker installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Caching essential assets');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => {
// Skip waiting to activate immediately
return self.skipWaiting();
})
);
});
Important concepts:
- event.waitUntil(): Tells the browser to wait until the promise resolves before considering installation complete
- caches.open(): Opens or creates a named cache
- cache.addAll(): Fetches and caches multiple resources
- self.skipWaiting(): Activates the Service Worker immediately instead of waiting for all pages to close
Phase 3: Activation
Activation happens after installation. This is where you clean up old caches and take control of pages.
// service-worker.js
self.addEventListener('activate', event => {
console.log('Service Worker activating...');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
// Delete old caches
if (cacheName !== CACHE_NAME) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
// Take control of all pages in scope
return self.clients.claim();
})
);
});
Key points:
- caches.keys(): Gets all cache names
- caches.delete(): Removes a specific cache
- self.clients.claim(): Takes control of pages immediately without waiting for reload
Phase 4: Fetch Handling
The fetch phase is where the Service Worker intercepts network requests and decides how to handle them.
// service-worker.js
self.addEventListener('fetch', event => {
// Only handle GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip cross-origin requests
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
event.respondWith(
caches.match(event.request)
.then(response => {
// Return cached response if available
if (response) {
return response;
}
// Otherwise fetch from network
return fetch(event.request);
})
.catch(() => {
// Return offline page if both cache and network fail
return caches.match('/offline.html');
})
);
});
Caching Strategies
Different types of content require different caching approaches. Here are the five main strategies:
1. Cache First (Cache, Falling Back to Network)
Serve from cache first, use network as fallback. Best for static assets that rarely change.
self.addEventListener('fetch', event => {
// Apply to images, CSS, JavaScript
if (event.request.destination === 'image' ||
event.request.destination === 'style' ||
event.request.destination === 'script') {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request).then(response => {
// Cache successful responses
if (response.ok) {
const cache = caches.open('static-v1');
cache.then(c => c.put(event.request, response.clone()));
}
return response;
});
})
.catch(() => {
// Return placeholder for failed images
if (event.request.destination === 'image') {
return caches.match('/images/placeholder.png');
}
})
);
}
});
Use cases:
- Images
- CSS and JavaScript files
- Fonts
- Static HTML pages
Pros: Fast, works offline Cons: May serve outdated content
2. Network First (Network, Falling Back to Cache)
Try network first, use cache as fallback. Best for frequently updated content.
self.addEventListener('fetch', event => {
// Apply to API calls and dynamic content
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
// Cache successful responses
if (response.ok) {
const cache = caches.open('api-v1');
cache.then(c => c.put(event.request, response.clone()));
}
return response;
})
.catch(() => {
// Fallback to cache
return caches.match(event.request)
.then(response => {
if (response) {
return response;
}
// Return offline response
return new Response(
JSON.stringify({ error: 'Offline' }),
{ headers: { 'Content-Type': 'application/json' } }
);
});
})
);
}
});
Use cases:
- API responses
- Dynamic content
- User-generated content
Pros: Always gets fresh content when available Cons: Slower when offline, requires network fallback
3. Stale While Revalidate
Serve cached content immediately, update in background. Best for content that can be slightly stale.
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Create fetch promise for background update
const fetchPromise = fetch(event.request)
.then(networkResponse => {
// Update cache in background
if (networkResponse.ok) {
const cache = caches.open('content-v1');
cache.then(c => c.put(event.request, networkResponse.clone()));
}
return networkResponse;
});
// Return cached response immediately, or network response if not cached
return response || fetchPromise;
})
.catch(() => {
// Return offline page if everything fails
return caches.match('/offline.html');
})
);
});
Use cases:
- Blog posts
- News articles
- Product listings
Pros: Fast, always gets fresh content eventually Cons: May show slightly outdated content initially
4. Network Only
Always fetch from network, never cache. Best for content that must always be fresh.
self.addEventListener('fetch', event => {
// Apply to sensitive operations
if (event.request.url.includes('/checkout') ||
event.request.url.includes('/payment')) {
event.respondWith(
fetch(event.request)
.catch(() => {
return new Response(
'Network required for this operation',
{ status: 503 }
);
})
);
}
});
Use cases:
- Payment processing
- Authentication
- Real-time data
Pros: Always fresh Cons: Doesn’t work offline
5. Cache Only
Always serve from cache, never fetch from network. Best for assets that never change.
self.addEventListener('fetch', event => {
// Apply to versioned assets
if (event.request.url.includes('/v1/assets/')) {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
// Return error if not in cache
return new Response('Not found', { status: 404 });
})
);
}
});
Use cases:
- Versioned assets
- Immutable resources
Pros: Fastest possible Cons: Requires careful versioning
Implementing Offline Functionality
Offline functionality goes beyond caching. It requires handling offline states, syncing data, and providing meaningful user feedback.
Detecting Offline Status
// Check current status
function isOnline() {
return navigator.onLine;
}
// Listen for connection changes
window.addEventListener('online', () => {
console.log('Back online');
syncPendingData();
updateUI();
});
window.addEventListener('offline', () => {
console.log('Gone offline');
updateUI();
});
// Update UI based on connection status
function updateUI() {
const statusElement = document.getElementById('connection-status');
if (navigator.onLine) {
statusElement.textContent = 'Online';
statusElement.className = 'online';
} else {
statusElement.textContent = 'Offline';
statusElement.className = 'offline';
}
}
Offline Fallback Page
Create a meaningful offline experience:
<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.offline-container {
text-align: center;
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 500px;
}
.offline-icon {
font-size: 64px;
margin-bottom: 20px;
}
h1 {
color: #333;
margin: 0 0 10px 0;
}
p {
color: #666;
line-height: 1.6;
}
.suggestions {
text-align: left;
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.suggestions h3 {
margin-top: 0;
color: #333;
}
.suggestions ul {
margin: 0;
padding-left: 20px;
}
.suggestions li {
color: #666;
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-icon">๐ก</div>
<h1>You're Offline</h1>
<p>It looks like you've lost your internet connection. Don't worry, you can still access cached content.</p>
<div class="suggestions">
<h3>What you can do:</h3>
<ul>
<li>Check your internet connection</li>
<li>Try refreshing the page</li>
<li>Browse previously visited pages</li>
<li>Come back when you're online</li>
</ul>
</div>
</div>
</body>
</html>
Background Sync
Sync data when connection is restored:
// Register sync in your app
async function registerSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('sync-data');
console.log('Sync registered');
} catch (error) {
console.error('Sync registration failed:', error);
}
}
}
// Handle sync in Service Worker
self.addEventListener('sync', event => {
if (event.tag === 'sync-data') {
event.waitUntil(syncDataWithServer());
}
});
async function syncDataWithServer() {
try {
// Get pending data from IndexedDB
const pendingData = await getPendingData();
// Send to server
const response = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pendingData)
});
if (response.ok) {
// Clear pending data
await clearPendingData();
console.log('Sync successful');
} else {
throw new Error('Sync failed');
}
} catch (error) {
console.error('Sync error:', error);
throw error; // Retry sync
}
}
Browser Support and Progressive Enhancement
Service Worker support varies across browsers. Always implement progressive enhancement:
// Check for Service Worker support
if ('serviceWorker' in navigator) {
// Register Service Worker
navigator.serviceWorker.register('/service-worker.js');
} else {
console.log('Service Workers not supported');
// App still works, just without offline support
}
// Check for specific features
if ('caches' in window) {
// Can use Cache API
}
if ('SyncManager' in window) {
// Can use Background Sync
}
if ('Notification' in window) {
// Can use Push Notifications
}
Browser support (as of 2025):
| Browser | Support |
|---|---|
| Chrome | Full support |
| Firefox | Full support |
| Safari | Partial (iOS 16.4+) |
| Edge | Full support |
| Opera | Full support |
| IE 11 | Not supported |
Security Considerations
HTTPS Requirement
Service Workers only work over HTTPS (except localhost for development):
// Check if HTTPS is available
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
console.warn('Service Workers require HTTPS');
}
Scope and Security
Service Workers operate within a defined scope:
// Register with specific scope
navigator.serviceWorker.register('/service-worker.js', {
scope: '/app/' // Only handles /app/* URLs
});
// In Service Worker, check scope
self.addEventListener('fetch', event => {
// Only handle requests within scope
if (event.request.url.startsWith(self.location.origin + '/app/')) {
// Handle request
}
});
Best Practices
1. Version Your Caches
Always version cache names to manage updates:
const CACHE_VERSION = 'v1';
const CACHE_NAME = `my-app-${CACHE_VERSION}`;
// Update version when deploying
// const CACHE_VERSION = 'v2';
2. Implement Update Checking
Check for updates periodically:
// In your app
setInterval(async () => {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
registration.update();
}
}, 60000); // Check every minute
// Listen for updates
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('New Service Worker activated');
// Notify user about update
});
3. Handle Errors Gracefully
Always provide fallbacks:
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.catch(() => {
// Return cached version
return caches.match(event.request)
.then(response => {
if (response) {
return response;
}
// Return offline page
return caches.match('/offline.html');
});
})
);
});
4. Test on Real Devices
Test on actual devices and networks:
// Add logging for debugging
self.addEventListener('fetch', event => {
console.log('Fetch:', event.request.url);
// ... handle request
});
self.addEventListener('install', event => {
console.log('Installing Service Worker');
// ... installation logic
});
self.addEventListener('activate', event => {
console.log('Activating Service Worker');
// ... activation logic
});
5. Limit Cache Size
Implement cache size management:
async function cleanupCache(cacheName, maxItems = 50) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxItems) {
// Delete oldest items
const itemsToDelete = keys.slice(0, keys.length - maxItems);
for (const key of itemsToDelete) {
await cache.delete(key);
}
}
}
// Call periodically
setInterval(() => {
cleanupCache('api-v1', 50);
}, 3600000); // Every hour
Debugging Service Workers
Using Chrome DevTools
- Open DevTools (F12)
- Go to Application tab
- Select Service Workers in the left sidebar
- View registered Service Workers and their status
Useful options:
- Offline: Simulate offline mode
- Update on reload: Force update on page reload
- Bypass for network: Ignore Service Worker for network requests
Logging and Monitoring
// Add comprehensive logging
self.addEventListener('install', event => {
console.log('[SW] Install event');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('[SW] Caching assets');
return cache.addAll(ASSETS_TO_CACHE);
})
.catch(error => {
console.error('[SW] Cache failed:', error);
})
);
});
self.addEventListener('fetch', event => {
console.log('[SW] Fetch:', event.request.url);
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
console.log('[SW] Serving from cache:', event.request.url);
return response;
}
console.log('[SW] Fetching from network:', event.request.url);
return fetch(event.request);
})
.catch(error => {
console.error('[SW] Fetch failed:', error);
return caches.match('/offline.html');
})
);
});
Common Pitfalls and Solutions
Pitfall 1: Service Worker Not Updating
Problem: Users see old cached content even after deployment.
Solution: Implement version checking:
// Check for updates on app load
window.addEventListener('load', () => {
navigator.serviceWorker.getRegistration()
.then(registration => {
if (registration) {
registration.update();
}
});
});
// Listen for updates
navigator.serviceWorker.addEventListener('controllerchange', () => {
// Notify user and reload
window.location.reload();
});
Pitfall 2: Caching Too Aggressively
Problem: Users can’t get new content even when online.
Solution: Use appropriate caching strategies:
// Don't cache API responses indefinitely
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
// Only cache successful responses
if (response.ok && response.status === 200) {
const cache = caches.open('api-v1');
cache.then(c => c.put(event.request, response.clone()));
}
return response;
})
.catch(() => caches.match(event.request))
);
}
});
Pitfall 3: Forgetting HTTPS
Problem: Service Worker won’t register in production.
Solution: Always use HTTPS:
// Check protocol
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
console.error('Service Workers require HTTPS');
}
Pitfall 4: Not Handling Errors
Problem: App breaks when cache fails or network is unavailable.
Solution: Always provide fallbacks:
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
if (!response || response.status !== 200) {
return caches.match(event.request);
}
return response;
})
.catch(() => {
return caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return caches.match('/offline.html');
});
})
);
});
Pitfall 5: Scope Issues
Problem: Service Worker doesn’t handle expected requests.
Solution: Verify scope and URL matching:
// Register with correct scope
navigator.serviceWorker.register('/service-worker.js', {
scope: '/' // Handles all URLs
});
// In Service Worker, verify URLs
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// Only handle same-origin requests
if (url.origin !== self.location.origin) {
return;
}
// Handle request
});
Real-World Example: Complete Offline-First App
Here’s a complete example combining all concepts:
// service-worker.js - Complete implementation
const CACHE_VERSION = 'v1';
const CACHE_NAME = `app-${CACHE_VERSION}`;
const RUNTIME_CACHE = `runtime-${CACHE_VERSION}`;
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html',
'/images/logo.png'
];
// Installation
self.addEventListener('install', event => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('[SW] Caching assets');
return cache.addAll(ASSETS_TO_CACHE);
})
.then(() => self.skipWaiting())
);
});
// Activation
self.addEventListener('activate', event => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
console.log('[SW] Deleting cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch handling with multiple strategies
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip cross-origin requests
if (url.origin !== self.location.origin) {
return;
}
// Cache-first for static assets
if (request.destination === 'image' ||
request.destination === 'style' ||
request.destination === 'script') {
event.respondWith(
caches.match(request)
.then(response => {
if (response) {
return response;
}
return fetch(request).then(response => {
if (response.ok) {
const cache = caches.open(RUNTIME_CACHE);
cache.then(c => c.put(request, response.clone()));
}
return response;
});
})
.catch(() => {
if (request.destination === 'image') {
return caches.match('/images/placeholder.png');
}
})
);
return;
}
// Network-first for API calls
if (url.pathname.includes('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
if (response.ok) {
const cache = caches.open(RUNTIME_CACHE);
cache.then(c => c.put(request, response.clone()));
}
return response;
})
.catch(() => {
return caches.match(request)
.then(response => {
if (response) {
return response;
}
return new Response(
JSON.stringify({ error: 'Offline' }),
{ headers: { 'Content-Type': 'application/json' } }
);
});
})
);
return;
}
// Stale-while-revalidate for HTML
event.respondWith(
caches.match(request)
.then(response => {
const fetchPromise = fetch(request)
.then(networkResponse => {
if (networkResponse.ok) {
const cache = caches.open(RUNTIME_CACHE);
cache.then(c => c.put(request, networkResponse.clone()));
}
return networkResponse;
});
return response || fetchPromise;
})
.catch(() => caches.match('/offline.html'))
);
});
Conclusion
Service Workers represent a fundamental shift in how we build web applications. They enable offline functionality, improved performance, and better user experiences regardless of network conditions.
Key takeaways:
- Service Workers are powerful: They act as a programmable proxy between your app and the network
- Lifecycle matters: Understanding registration, installation, activation, and fetch handling is crucial
- Choose the right strategy: Different content types need different caching approaches
- Progressive enhancement: Always provide fallbacks for browsers without Service Worker support
- Security first: HTTPS is required, and scope matters
- Test thoroughly: Test on real devices and networks, not just in DevTools
- Monitor and debug: Use logging and DevTools to understand what’s happening
Next steps:
- Start with a simple Service Worker that caches static assets
- Implement offline fallback pages
- Add network-first caching for API calls
- Implement background sync for data persistence
- Monitor performance and user experience
- Iterate based on real-world usage
Service Workers are now widely supported and production-ready. By implementing them thoughtfully, you can build web applications that work reliably for all users, regardless of their network conditions. Your users will appreciate the faster, more resilient experience you provide.
Comments