Service Workers: Offline-First Web Applications
A comprehensive guide to building offline-first web applications with service workers covering caching strategies, background sync, push notifications, and PWA fundamentals.
Service Workers: Offline-First Web Applications
Overview
Service workers fundamentally changed what web applications can do. Before service workers, a web app without a network connection was a dead web app. The browser would show its default offline page, and your users would stare at a dinosaur or a sad face until connectivity returned. That is no longer acceptable.
A service worker is a JavaScript file that runs in a separate thread from your main page. It acts as a programmable network proxy, intercepting every request your application makes and giving you full control over how those requests are fulfilled. You can serve responses from a cache, fetch them from the network, construct them on the fly, or combine strategies depending on the type of resource. This is the foundation of offline-first architecture.
I have been building progressive web applications for production environments since 2017, and service workers remain one of the most powerful and most misunderstood browser APIs available. They unlock offline support, background sync, push notifications, and installation on the home screen. But they also introduce complexity around caching, versioning, and update propagation that will bite you if you do not plan for it.
This article walks through every aspect of service workers that matters in practice. We will cover the lifecycle, caching strategies, background sync, push notifications, debugging, and a complete working example you can adapt for your own projects.
Prerequisites
- Solid understanding of JavaScript and the browser event model
- Familiarity with Promises and asynchronous programming
- A web server capable of serving files over HTTPS (service workers require a secure context, except on localhost)
- Chrome DevTools or Firefox Developer Tools for debugging
- Basic understanding of HTTP caching headers
Service Worker Lifecycle
The service worker lifecycle is the single most important concept to understand. Get this wrong and you will spend hours debugging stale caches and phantom updates.
Registration
Registration happens from your main page JavaScript. The browser downloads the service worker file, parses it, and begins the installation process.
// main.js - runs in your page context
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(function(registration) {
console.log('SW registered with scope:', registration.scope);
})
.catch(function(error) {
console.log('SW registration failed:', error);
});
});
}
The scope parameter controls which pages the service worker can intercept. A service worker at /app/sw.js can only control pages under /app/ by default. Placing your service worker file at the root gives it control over the entire origin. You can narrow scope but never widen it beyond the service worker file location unless you set a Service-Worker-Allowed header on the server.
Install Event
The install event fires once per service worker version. This is where you precache your static assets, the app shell that makes your application work offline.
// sw.js
var CACHE_NAME = 'app-shell-v1';
var PRECACHE_URLS = [
'/',
'/index.html',
'/css/styles.css',
'/js/app.js',
'/js/vendor.js',
'/images/logo.png',
'/offline.html'
];
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('Precaching app shell');
return cache.addAll(PRECACHE_URLS);
})
);
});
event.waitUntil() tells the browser not to terminate the service worker until the promise resolves. If any single asset in addAll fails to cache, the entire installation fails. This is intentional. You want an all-or-nothing guarantee for your app shell.
Activate Event
The activate event fires after the install completes and the previous service worker (if any) has been replaced. This is where you clean up old caches.
self.addEventListener('activate', function(event) {
var cacheWhitelist = [CACHE_NAME, 'api-cache-v1'];
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
if (cacheWhitelist.indexOf(cacheName) === -1) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
Fetch Event
The fetch event fires for every network request made by pages the service worker controls. This is where your caching strategies live.
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
A critical detail: a new service worker installs but does not activate until all tabs controlled by the old service worker are closed. The new worker sits in a "waiting" state. This prevents breaking an active session with a mid-use cache swap.
Cache API Fundamentals
The Cache API is a key-value store where keys are Request objects and values are Response objects. It is entirely separate from the browser HTTP cache.
// Open or create a cache
caches.open('my-cache-v1').then(function(cache) {
// Add a URL (fetches and stores)
cache.add('/api/data');
// Add multiple URLs
cache.addAll(['/page1.html', '/page2.html']);
// Manually put a request/response pair
var request = new Request('/api/config');
var response = new Response(JSON.stringify({ theme: 'dark' }), {
headers: { 'Content-Type': 'application/json' }
});
cache.put(request, response);
// Match a request
cache.match('/api/data').then(function(response) {
if (response) {
return response.json();
}
});
// Delete an entry
cache.delete('/api/data');
});
// List all cache names
caches.keys().then(function(names) {
console.log('Caches:', names);
});
// Delete an entire cache
caches.delete('old-cache-v1');
One thing that trips people up: cache.match() matches on the request URL and method. Query parameters matter. If you cache /api/data?page=1, a match for /api/data?page=2 returns undefined. Use the ignoreSearch option if you want to match regardless of query strings.
Caching Strategies
Different resources demand different strategies. Using the wrong strategy for a resource type is the most common service worker mistake I see in production.
Cache First (Cache Falling Back to Network)
Best for: static assets that are versioned (hashed filenames), fonts, images.
function cacheFirst(request) {
return caches.match(request).then(function(cached) {
if (cached) {
return cached;
}
return fetch(request).then(function(response) {
var responseClone = response.clone();
caches.open('static-v1').then(function(cache) {
cache.put(request, responseClone);
});
return response;
});
});
}
Network First (Network Falling Back to Cache)
Best for: API requests, HTML pages where freshness matters more than speed.
function networkFirst(request) {
return fetch(request).then(function(response) {
var responseClone = response.clone();
caches.open('api-cache-v1').then(function(cache) {
cache.put(request, responseClone);
});
return response;
}).catch(function() {
return caches.match(request);
});
}
Stale While Revalidate
Best for: resources where you want speed but also freshness, like user avatars, non-critical API data, and frequently updated assets.
function staleWhileRevalidate(request) {
return caches.open('swr-cache-v1').then(function(cache) {
return cache.match(request).then(function(cached) {
var fetchPromise = fetch(request).then(function(response) {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
});
});
}
This is my default strategy for most dynamic content. The user gets an instant response from cache while the cache silently updates in the background. The next visit sees the fresh data.
Cache Only
Best for: precached assets you know will always be in the cache.
function cacheOnly(request) {
return caches.match(request);
}
Network Only
Best for: non-GET requests, analytics pings, anything that should never be cached.
function networkOnly(request) {
return fetch(request);
}
Applying Strategies by Route
A production service worker routes requests to different strategies based on the URL pattern.
self.addEventListener('fetch', function(event) {
var url = new URL(event.request.url);
// HTML pages - network first
if (event.request.headers.get('accept').indexOf('text/html') !== -1) {
event.respondWith(networkFirst(event.request));
return;
}
// API calls - stale while revalidate
if (url.pathname.indexOf('/api/') === 0) {
event.respondWith(staleWhileRevalidate(event.request));
return;
}
// Static assets - cache first
if (url.pathname.match(/\.(js|css|png|jpg|svg|woff2?)$/)) {
event.respondWith(cacheFirst(event.request));
return;
}
// Everything else - network first
event.respondWith(networkFirst(event.request));
});
Offline Fallback Pages
When a network-first request fails and there is no cached version, you need a fallback. Precache an offline page during install and serve it when everything else fails.
self.addEventListener('fetch', function(event) {
if (event.request.headers.get('accept').indexOf('text/html') !== -1) {
event.respondWith(
fetch(event.request).catch(function() {
return caches.match(event.request).then(function(cached) {
return cached || caches.match('/offline.html');
});
})
);
}
});
Your offline page should be self-contained. Inline its CSS. Do not depend on external scripts. Give users something useful: a message explaining they are offline, a list of cached pages they can still access, and a retry button.
Background Sync
Background sync lets you defer actions until the user has connectivity. This is essential for forms, comments, likes, and any write operation.
// In your main page - queue the action
navigator.serviceWorker.ready.then(function(registration) {
return registration.sync.register('sync-form-data');
});
// Store the data in IndexedDB before registering sync
function queueFormSubmission(data) {
return openDB().then(function(db) {
var tx = db.transaction('outbox', 'readwrite');
tx.objectStore('outbox').put({
url: '/api/submit',
method: 'POST',
body: JSON.stringify(data),
timestamp: Date.now()
});
return tx.complete;
}).then(function() {
return navigator.serviceWorker.ready;
}).then(function(registration) {
return registration.sync.register('sync-outbox');
});
}
// In the service worker - process the queue when online
self.addEventListener('sync', function(event) {
if (event.tag === 'sync-outbox') {
event.waitUntil(processOutbox());
}
});
function processOutbox() {
return openDB().then(function(db) {
var tx = db.transaction('outbox', 'readonly');
return tx.objectStore('outbox').getAll();
}).then(function(entries) {
return Promise.all(entries.map(function(entry) {
return fetch(entry.url, {
method: entry.method,
headers: { 'Content-Type': 'application/json' },
body: entry.body
}).then(function() {
return removeFromOutbox(entry.id);
});
}));
});
}
Background sync fires a sync event in the service worker when the browser detects connectivity. The browser may retry multiple times with exponential backoff. If the user closes the tab, the sync still happens when they reopen the browser. This is genuinely powerful.
Push Notifications
Push notifications require a push subscription, a server-side push service, and user permission.
// Request permission and subscribe
function subscribeToPush(registration) {
return registration.pushManager.subscribe({
userVisibleNotification: true,
applicationServerKey: urlBase64ToUint8Array(
'YOUR_VAPID_PUBLIC_KEY'
)
}).then(function(subscription) {
// Send subscription to your server
return fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
});
}
// In the service worker - handle push events
self.addEventListener('push', function(event) {
var data = event.data ? event.data.json() : {};
var title = data.title || 'New Notification';
var options = {
body: data.body || '',
icon: '/images/icon-192.png',
badge: '/images/badge-72.png',
data: { url: data.url || '/' }
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// Handle notification clicks
self.addEventListener('notificationclick', function(event) {
event.notification.close();
var url = event.notification.data.url;
event.waitUntil(
clients.matchAll({ type: 'window' }).then(function(windowClients) {
for (var i = 0; i < windowClients.length; i++) {
if (windowClients[i].url === url && 'focus' in windowClients[i]) {
return windowClients[i].focus();
}
}
return clients.openWindow(url);
})
);
});
Use VAPID (Voluntary Application Server Identification) keys. Generate them with the web-push library on Node.js. Never request push permission on page load. Wait until the user takes an action that signals interest, like clicking a "notify me" button. Unsolicited permission prompts have abysmal acceptance rates.
Updating Service Workers
When you change your service worker file, the browser detects the byte difference and triggers a new install. But the new worker waits until all controlled tabs close. You can force immediate activation:
// In the service worker
self.addEventListener('install', function(event) {
self.skipWaiting(); // Activate immediately, do not wait
});
self.addEventListener('activate', function(event) {
event.waitUntil(
clients.claim() // Take control of all open tabs immediately
);
});
Use skipWaiting and clients.claim together when cache compatibility between versions is not a concern. If your new cache structure is incompatible with old page code, forcing activation mid-session can break things. In those cases, show a "new version available" banner and let the user reload.
// In your page - detect waiting worker and prompt for update
navigator.serviceWorker.register('/sw.js').then(function(reg) {
reg.addEventListener('updatefound', function() {
var newWorker = reg.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner();
}
});
});
});
function showUpdateBanner() {
var banner = document.getElementById('update-banner');
banner.style.display = 'block';
document.getElementById('update-btn').addEventListener('click', function() {
navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
});
}
// In the service worker - listen for the skip message
self.addEventListener('message', function(event) {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
Service Worker Communication with postMessage
The main thread and service worker communicate through postMessage. This is useful for sending cache status updates, triggering operations, or sharing data.
// Page to service worker
navigator.serviceWorker.controller.postMessage({
type: 'CACHE_URLS',
urls: ['/articles/1', '/articles/2']
});
// Service worker to all clients
self.clients.matchAll().then(function(clientList) {
clientList.forEach(function(client) {
client.postMessage({
type: 'CACHE_UPDATED',
url: '/api/data'
});
});
});
// Page listening for service worker messages
navigator.serviceWorker.addEventListener('message', function(event) {
if (event.data.type === 'CACHE_UPDATED') {
console.log('Cache updated for:', event.data.url);
refreshContent();
}
});
Debugging Service Workers in DevTools
Chrome DevTools has dedicated service worker tooling under Application > Service Workers. Here is what you should know:
- Update on reload: Forces a new install and activate on every page refresh. Essential during development.
- Bypass for network: Disables fetch event interception. Useful for testing non-SW behavior.
- Offline checkbox: Simulates offline mode. Test your fallback pages here.
- Cache Storage: Inspect every cache and its entries under Application > Cache Storage.
- Unregister: Removes the service worker entirely. Use this when debugging stale state.
Firefox offers similar tools under the Application panel. For production debugging, add console.log statements in your service worker events. They appear in the browser console even though the worker runs in a separate thread.
One debugging pattern I rely on: version your caches with timestamps during development so you can immediately see which version is active.
var CACHE_NAME = 'app-shell-v' + Date.now(); // Dev only. Use static versions in production.
Workbox Library Overview
Google's Workbox library abstracts the boilerplate of service worker caching. If you do not want to write raw Cache API code, Workbox is the industry standard.
// Using Workbox via importScripts
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
workbox.routing.registerRoute(
function(context) { return context.request.destination === 'image'; },
new workbox.strategies.CacheFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
})
]
})
);
workbox.routing.registerRoute(
function(context) { return context.url.pathname.indexOf('/api/') === 0; },
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'api-responses'
})
);
workbox.precaching.precacheAndRoute([
{ url: '/index.html', revision: 'abc123' },
{ url: '/css/styles.css', revision: 'def456' },
{ url: '/js/app.js', revision: 'ghi789' }
]);
Workbox also provides a CLI and Webpack plugin for generating precache manifests automatically from your build output. It handles cache versioning, expiration, and cleanup so you do not have to.
manifest.json for PWA Installability
A service worker alone does not make a PWA installable. You also need a web app manifest.
{
"name": "My Offline App",
"short_name": "OfflineApp",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"icons": [
{
"src": "/images/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Link it in your HTML:
<link rel="manifest" href="/manifest.json">
Chrome requires a service worker with a fetch handler, a manifest with name, icons, start_url, and display, and the app served over HTTPS before it will show the install prompt.
Handling Cache Storage Limits
Browsers enforce storage quotas. Chrome gives an origin up to 80% of total disk space. Safari is more aggressive, evicting service worker caches after 7 days of inactivity in some versions.
Protect against storage pressure:
// Check available storage
if (navigator.storage && navigator.storage.estimate) {
navigator.storage.estimate().then(function(estimate) {
var percentUsed = (estimate.usage / estimate.quota * 100).toFixed(2);
console.log('Storage: ' + percentUsed + '% used');
console.log('Quota: ' + (estimate.quota / 1024 / 1024).toFixed(0) + ' MB');
});
}
// Request persistent storage (prevents eviction)
if (navigator.storage && navigator.storage.persist) {
navigator.storage.persist().then(function(granted) {
console.log('Persistent storage granted:', granted);
});
}
Implement cache size limits manually or use Workbox's ExpirationPlugin. Cap your dynamic caches at a reasonable entry count and age.
function trimCache(cacheName, maxItems) {
caches.open(cacheName).then(function(cache) {
cache.keys().then(function(keys) {
if (keys.length > maxItems) {
cache.delete(keys[0]).then(function() {
trimCache(cacheName, maxItems);
});
}
});
});
}
Complete Working Example
Here is a complete offline-first application with service worker. This example includes an app shell with precaching, stale-while-revalidate for API data, an offline fallback page, background sync for form submissions, and cache versioning.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline-First Notes App</title>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a73e8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 640px; margin: 0 auto; padding: 20px; }
h1 { margin-bottom: 20px; }
.status { padding: 8px 12px; border-radius: 4px; margin-bottom: 16px; font-size: 14px; }
.status.offline { background: #fff3cd; color: #856404; }
.status.online { background: #d4edda; color: #155724; display: none; }
form { margin-bottom: 24px; }
textarea { width: 100%; height: 80px; padding: 10px; border: 1px solid #ddd; border-radius: 4px; resize: vertical; }
button { margin-top: 8px; padding: 10px 20px; background: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; }
.note { padding: 12px; border: 1px solid #eee; border-radius: 4px; margin-bottom: 8px; }
.note .meta { font-size: 12px; color: #888; margin-top: 4px; }
.update-banner { display: none; padding: 12px; background: #e8f0fe; text-align: center; margin-bottom: 16px; border-radius: 4px; }
</style>
</head>
<body>
<div class="update-banner" id="update-banner">
A new version is available.
<button id="update-btn">Update Now</button>
</div>
<div class="status online" id="status">You are online</div>
<div class="status offline" id="offline-status" style="display:none">
You are offline. Changes will sync when you reconnect.
</div>
<h1>Notes</h1>
<form id="note-form">
<textarea id="note-input" placeholder="Write a note..."></textarea>
<button type="submit">Save Note</button>
</form>
<div id="notes-list"></div>
<script src="/js/app.js"></script>
</body>
</html>
js/app.js
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js')
.then(function(reg) {
console.log('SW registered');
// Detect updates
reg.addEventListener('updatefound', function() {
var newWorker = reg.installing;
newWorker.addEventListener('statechange', function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
document.getElementById('update-banner').style.display = 'block';
}
});
});
});
// Handle update button
document.getElementById('update-btn').addEventListener('click', function() {
navigator.serviceWorker.controller.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
});
});
}
// Online/offline status
window.addEventListener('online', function() {
document.getElementById('status').style.display = 'block';
document.getElementById('offline-status').style.display = 'none';
});
window.addEventListener('offline', function() {
document.getElementById('status').style.display = 'none';
document.getElementById('offline-status').style.display = 'block';
});
// Load notes
function loadNotes() {
fetch('/api/notes')
.then(function(response) { return response.json(); })
.then(function(notes) {
renderNotes(notes);
})
.catch(function(err) {
console.log('Failed to load notes:', err);
});
}
function renderNotes(notes) {
var list = document.getElementById('notes-list');
list.innerHTML = '';
notes.forEach(function(note) {
var div = document.createElement('div');
div.className = 'note';
div.innerHTML = '<p>' + escapeHtml(note.text) + '</p>' +
'<div class="meta">' + new Date(note.timestamp).toLocaleString() + '</div>';
list.appendChild(div);
});
}
function escapeHtml(text) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(text));
return div.innerHTML;
}
// Submit note with background sync fallback
document.getElementById('note-form').addEventListener('submit', function(e) {
e.preventDefault();
var input = document.getElementById('note-input');
var text = input.value.trim();
if (!text) return;
var noteData = { text: text, timestamp: Date.now() };
if ('serviceWorker' in navigator && 'SyncManager' in window) {
// Store in IndexedDB and use background sync
storeForSync(noteData).then(function() {
return navigator.serviceWorker.ready;
}).then(function(reg) {
return reg.sync.register('sync-notes');
}).then(function() {
input.value = '';
loadNotes();
}).catch(function() {
// Fallback to direct fetch
submitNote(noteData, input);
});
} else {
submitNote(noteData, input);
}
});
function submitNote(noteData, input) {
fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(noteData)
}).then(function() {
input.value = '';
loadNotes();
});
}
// IndexedDB helpers for background sync
function openSyncDB() {
return new Promise(function(resolve, reject) {
var request = indexedDB.open('sync-db', 1);
request.onupgradeneeded = function(e) {
e.target.result.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
};
request.onsuccess = function(e) { resolve(e.target.result); };
request.onerror = function(e) { reject(e.target.error); };
});
}
function storeForSync(data) {
return openSyncDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction('outbox', 'readwrite');
tx.objectStore('outbox').add(data);
tx.oncomplete = function() { resolve(); };
tx.onerror = function(e) { reject(e.target.error); };
});
});
}
// Initial load
loadNotes();
sw.js
var APP_SHELL_CACHE = 'app-shell-v1';
var API_CACHE = 'api-cache-v1';
var ALL_CACHES = [APP_SHELL_CACHE, API_CACHE];
var PRECACHE_URLS = [
'/',
'/index.html',
'/js/app.js',
'/offline.html',
'/manifest.json'
];
// Install - precache app shell
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(APP_SHELL_CACHE).then(function(cache) {
return cache.addAll(PRECACHE_URLS);
})
);
});
// Activate - clean old caches
self.addEventListener('activate', function(event) {
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(name) {
if (ALL_CACHES.indexOf(name) === -1) {
return caches.delete(name);
}
})
);
}).then(function() {
return self.clients.claim();
})
);
});
// Fetch - route to strategies
self.addEventListener('fetch', function(event) {
var url = new URL(event.request.url);
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// API requests - stale while revalidate
if (url.pathname.indexOf('/api/') === 0) {
event.respondWith(
caches.open(API_CACHE).then(function(cache) {
return cache.match(event.request).then(function(cached) {
var fetchPromise = fetch(event.request).then(function(response) {
if (response.ok) {
cache.put(event.request, response.clone());
}
return response;
}).catch(function() {
return cached;
});
return cached || fetchPromise;
});
})
);
return;
}
// HTML - network first with offline fallback
if (event.request.headers.get('accept') &&
event.request.headers.get('accept').indexOf('text/html') !== -1) {
event.respondWith(
fetch(event.request).then(function(response) {
var clone = response.clone();
caches.open(APP_SHELL_CACHE).then(function(cache) {
cache.put(event.request, clone);
});
return response;
}).catch(function() {
return caches.match(event.request).then(function(cached) {
return cached || caches.match('/offline.html');
});
})
);
return;
}
// Everything else - cache first
event.respondWith(
caches.match(event.request).then(function(cached) {
return cached || fetch(event.request).then(function(response) {
if (response.ok) {
var clone = response.clone();
caches.open(APP_SHELL_CACHE).then(function(cache) {
cache.put(event.request, clone);
});
}
return response;
});
})
);
});
// Background sync
self.addEventListener('sync', function(event) {
if (event.tag === 'sync-notes') {
event.waitUntil(syncNotes());
}
});
function syncNotes() {
return openSyncDB().then(function(db) {
return getAllFromStore(db, 'outbox');
}).then(function(entries) {
return Promise.all(entries.map(function(entry) {
return fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: entry.text, timestamp: entry.timestamp })
}).then(function() {
return deleteFromStore(entry.id);
});
}));
});
}
function openSyncDB() {
return new Promise(function(resolve, reject) {
var request = indexedDB.open('sync-db', 1);
request.onupgradeneeded = function(e) {
e.target.result.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
};
request.onsuccess = function(e) { resolve(e.target.result); };
request.onerror = function(e) { reject(e.target.error); };
});
}
function getAllFromStore(db, storeName) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(storeName, 'readonly');
var request = tx.objectStore(storeName).getAll();
request.onsuccess = function() { resolve(request.result); };
request.onerror = function() { reject(request.error); };
});
}
function deleteFromStore(id) {
return openSyncDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction('outbox', 'readwrite');
tx.objectStore('outbox').delete(id);
tx.oncomplete = function() { resolve(); };
tx.onerror = function(e) { reject(e.target.error); };
});
});
}
// Listen for skip waiting message
self.addEventListener('message', function(event) {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
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, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; text-align: center; padding: 20px; background: #f5f5f5; }
.container { max-width: 400px; }
h1 { font-size: 48px; margin-bottom: 8px; }
p { color: #666; margin-bottom: 20px; }
button { padding: 12px 24px; background: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
</style>
</head>
<body>
<div class="container">
<h1>Offline</h1>
<p>You are not connected to the internet. Your saved notes are still available when you reconnect.</p>
<button onclick="window.location.reload()">Try Again</button>
</div>
</body>
</html>
Common Issues and Troubleshooting
Service worker does not update after code changes. The browser only checks for updates when you navigate to a page controlled by the service worker. During development, enable "Update on reload" in DevTools. In production, the browser checks every 24 hours at minimum. You can force a check with registration.update().
Cached API responses show stale data. If you are using cache-first for API responses, users will never see fresh data until the cache expires or is manually invalidated. Switch to stale-while-revalidate or network-first for data that changes frequently. Use postMessage to notify the page when the background revalidation completes so the UI can refresh.
Service worker intercepts requests it should not. A common problem when your scope is too broad. If your service worker is at / and you have admin routes at /admin/ that should bypass the cache, add explicit exclusions in your fetch handler. Check event.request.url and call return without event.respondWith() for requests you want to pass through normally.
CORS errors when caching third-party resources. When caching cross-origin responses, you get opaque responses (status 0) that the Cache API stores but cannot inspect. These opaque responses can consume significant storage space (padded to 7MB in Chrome). Either proxy third-party resources through your own server or accept the storage overhead and handle opaque responses explicitly.
Registration fails on HTTP. Service workers require HTTPS. The only exception is localhost. If you are testing on a local network device, use a tool like ngrok to get an HTTPS tunnel, or configure a self-signed certificate.
Form submissions lost when offline without background sync. Not all browsers support the Background Sync API. Always check for SyncManager in window before relying on it. Implement a fallback that retries the fetch when the online event fires on the window object.
Best Practices
Version your caches explicitly. Use names like
app-shell-v2,api-cache-v3. Never use a single cache for everything. Separate caches by purpose so you can update the app shell without blowing away cached API data.Always clean up old caches in the activate event. Maintain a whitelist of current cache names and delete everything else. Abandoned caches consume storage and cause confusion during debugging.
Clone responses before caching. Response bodies can only be read once. If you pass a response to the cache and also return it to the page, one of them will get an empty body. Always call
response.clone()beforecache.put().Do not precache too much. Only precache assets required for the app shell. Dynamic content should be cached on demand. Precaching 50MB of assets on first visit is a terrible user experience on mobile networks.
Use navigation preload when available. Without it, the service worker boot time adds latency to navigation requests. Navigation preload lets the browser start the network request while the service worker is starting up.
self.addEventListener('activate', function(event) { event.waitUntil( self.registration.navigationPreload && self.registration.navigationPreload.enable() ); });Test offline behavior systematically. Do not just flip the offline toggle in DevTools and call it done. Test with slow connections, test closing and reopening the browser, test clearing site data, and test with multiple tabs open. Service worker bugs often only appear in specific lifecycle states.
Provide clear offline UI feedback. Never leave the user guessing. Show connection status, indicate when data is stale, and confirm when queued actions have been synced. Silent failures are the worst user experience.
Set cache expiration policies. Without expiration, your caches grow indefinitely. Use Workbox's ExpirationPlugin or write manual cleanup logic that limits cache entries by count and age.