Skip to main content
โšก Calmops

Service Workers and Offline Functionality: Building Resilient Web Applications

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

  1. Open DevTools (F12)
  2. Go to Application tab
  3. Select Service Workers in the left sidebar
  4. 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:

  1. Service Workers are powerful: They act as a programmable proxy between your app and the network
  2. Lifecycle matters: Understanding registration, installation, activation, and fetch handling is crucial
  3. Choose the right strategy: Different content types need different caching approaches
  4. Progressive enhancement: Always provide fallbacks for browsers without Service Worker support
  5. Security first: HTTPS is required, and scope matters
  6. Test thoroughly: Test on real devices and networks, not just in DevTools
  7. Monitor and debug: Use logging and DevTools to understand what’s happening

Next steps:

  1. Start with a simple Service Worker that caches static assets
  2. Implement offline fallback pages
  3. Add network-first caching for API calls
  4. Implement background sync for data persistence
  5. Monitor performance and user experience
  6. 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