Service Workers and PWA
1. Service Worker (navigator.serviceWorker.register, install)
| Method | Syntax | Description | Browser Support |
|---|---|---|---|
| register | navigator.serviceWorker.register(scriptURL, options) |
Registers service worker. Returns Promise<ServiceWorkerRegistration>. Requires HTTPS. | All Modern Browsers |
| getRegistration | navigator.serviceWorker.getRegistration(scope) |
Gets existing registration for scope. Returns Promise<ServiceWorkerRegistration>. | All Modern Browsers |
| getRegistrations | navigator.serviceWorker.getRegistrations() |
Gets all registrations. Returns Promise<ServiceWorkerRegistration[]>. | All Modern Browsers |
| Lifecycle Event | When Fired | Use Case |
|---|---|---|
| install | Service worker first installed. Runs only once per version. | Cache static assets, setup |
| activate | Service worker activated. After install or on page reload for updated worker. | Clean old caches, claim clients |
| fetch | Network request intercepted. Fires for all page requests. | Cache strategies, offline support |
| message | Message received from client (page/worker). | Client-worker communication |
| sync | Background sync triggered (when online). | Retry failed requests |
| push | Push notification received. | Show notifications |
| ServiceWorkerRegistration Property | Type | Description |
|---|---|---|
| installing | ServiceWorker |
Service worker currently installing. null if none. |
| waiting | ServiceWorker |
Service worker installed, waiting to activate. null if none. |
| active | ServiceWorker |
Active service worker controlling pages. null if none. |
| scope | string |
URL scope of service worker (e.g., "/app/"). |
| updateViaCache | string |
Cache mode for updates: "imports", "all", "none". |
Example: Register service worker
// Check support
if ("serviceWorker" in navigator) {
console.log("Service Workers supported");
} else {
console.log("Service Workers not supported");
}
// Register service worker
async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
"scope": "/" // Default is script location
});
console.log("Service Worker registered:", registration.scope);
// Check state
if (registration.installing) {
console.log("Service Worker installing");
} else if (registration.waiting) {
console.log("Service Worker waiting");
} else if (registration.active) {
console.log("Service Worker active");
}
return registration;
} catch (error) {
console.error("Service Worker registration failed:", error);
}
}
// Register on page load
window.addEventListener("load", () => {
registerServiceWorker();
});
// Listen for updates
navigator.serviceWorker.addEventListener("controllerchange", () => {
console.log("Service Worker controller changed");
// New service worker took control
window.location.reload();
});
// Check for updates manually
async function checkForUpdates() {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
console.log("Checked for updates");
}
}
// Unregister service worker
async function unregisterServiceWorker() {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
const success = await registration.unregister();
console.log("Unregistered:", success);
}
}
Example: Service worker lifecycle (sw.js)
const CACHE_NAME = "my-app-v1";
const STATIC_ASSETS = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/logo.png"
];
// Install event - cache static assets
self.addEventListener("install", (event) => {
console.log("Service Worker installing");
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log("Caching static assets");
return cache.addAll(STATIC_ASSETS);
}).then(() => {
// Skip waiting to activate immediately
return self.skipWaiting();
})
);
});
// Activate event - clean old caches
self.addEventListener("activate", (event) => {
console.log("Service Worker activating");
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log("Deleting old cache:", name);
return caches.delete(name);
})
);
}).then(() => {
// Take control of all clients immediately
return self.clients.claim();
})
);
});
// Fetch event - serve from cache or network
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Cache hit - return cached response
if (response) {
return response;
}
// Cache miss - fetch from network
return fetch(event.request).then((response) => {
// Don't cache non-GET requests or non-ok responses
if (event.request.method !== "GET" || !response.ok) {
return response;
}
// Clone response (can only read once)
const responseToCache = response.clone();
// Cache for next time
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
}).catch(() => {
// Network failed - return offline page
return caches.match("/offline.html");
})
);
});
Note: Service Workers require HTTPS (localhost OK for development). Only one service worker active per scope. Use
skipWaiting() to activate immediately, clients.claim() to control existing pages. Updates check every 24 hours or on navigation. Use versioned cache names to manage updates.
Warning: Service worker has no DOM access - can't manipulate page directly. Runs in separate thread. Use
event.waitUntil() to extend event lifetime for async operations. Service worker can be terminated anytime - don't rely on global state. Test offline behavior thoroughly.
2. Service Worker Messages (postMessage, onmessage)
| Method | From | To | Syntax |
|---|---|---|---|
| postMessage | Page | Service Worker | navigator.serviceWorker.controller.postMessage(data) |
| postMessage | Service Worker | Page | client.postMessage(data) |
| postMessage | Service Worker | All Pages | clients.matchAll().then(clients => clients.forEach(c => c.postMessage(data))) |
| Clients Method | Description | Use Case |
|---|---|---|
| clients.matchAll(options) | Gets all client windows/tabs. Options: includeUncontrolled, type. |
Broadcast to all tabs |
| clients.get(id) | Gets specific client by ID. Returns Promise<Client>. | Reply to specific tab |
| clients.openWindow(url) | Opens new window/tab. Returns Promise<WindowClient>. Requires user interaction. | Open notification click |
| clients.claim() | Makes service worker control all clients immediately (without reload). | Take control on activate |
Example: Page to service worker communication
// From page to service worker
if (navigator.serviceWorker.controller) {
// Send message
navigator.serviceWorker.controller.postMessage({
"type": "CACHE_URLS",
"urls": ["/page1.html", "/page2.html"]
});
console.log("Message sent to service worker");
} else {
console.log("No active service worker");
}
// Listen for messages from service worker
navigator.serviceWorker.addEventListener("message", (event) => {
console.log("Message from service worker:", event.data);
if (event.data.type === "CACHE_UPDATED") {
console.log("Cache updated:", event.data.urls);
} else if (event.data.type === "NEW_VERSION") {
showUpdatePrompt();
}
});
// Request-response pattern with message channel
function sendMessageWithResponse(message) {
return new Promise((resolve, reject) => {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event) => {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
};
navigator.serviceWorker.controller.postMessage(
message,
[messageChannel.port2]
);
});
}
// Usage
async function getCacheInfo() {
try {
const response = await sendMessageWithResponse({
"type": "GET_CACHE_INFO"
});
console.log("Cache info:", response);
} catch (error) {
console.error("Error:", error);
}
}
Example: Service worker to page communication
// In service worker (sw.js)
// Listen for messages from pages
self.addEventListener("message", (event) => {
console.log("Message received:", event.data);
if (event.data.type === "CACHE_URLS") {
// Cache requested URLs
cacheUrls(event.data.urls).then(() => {
// Reply to sender
event.source.postMessage({
"type": "CACHE_UPDATED",
"urls": event.data.urls
});
});
} else if (event.data.type === "GET_CACHE_INFO") {
// Handle request-response with message channel
caches.keys().then((cacheNames) => {
event.ports[0].postMessage({
"caches": cacheNames,
"count": cacheNames.length
});
});
} else if (event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
// Broadcast to all clients
async function notifyAllClients(message) {
const clients = await self.clients.matchAll({
"includeUncontrolled": true,
"type": "window"
});
clients.forEach((client) => {
client.postMessage(message);
});
}
// Notify when cache updated
async function cacheUrls(urls) {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(urls);
// Notify all tabs
await notifyAllClients({
"type": "CACHE_UPDATED",
"urls": urls
});
}
// Notify specific client
async function notifyClient(clientId, message) {
const client = await self.clients.get(clientId);
if (client) {
client.postMessage(message);
}
}
// Open window on notification click
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow("/notifications")
);
});
Note:
postMessage only works if service worker is active and controlling page. Check navigator.serviceWorker.controller before sending. Messages are structured clones - can't send functions, DOM nodes. Use MessageChannel for request-response patterns.
3. Background Sync (sync, periodicSync)
| API | Method | Description | Browser Support |
|---|---|---|---|
| Background Sync | registration.sync.register(tag) |
Registers one-time sync when online. Returns Promise. | Chrome, Edge |
| Periodic Sync | registration.periodicSync.register(tag, options) |
Registers periodic sync. Options: minInterval in ms. Requires installed PWA. |
Limited (Chrome) |
| Get Tags | registration.sync.getTags() |
Gets pending sync tags. Returns Promise<string[]>. | Chrome, Edge |
| Unregister | registration.periodicSync.unregister(tag) |
Unregisters periodic sync. Returns Promise. | Limited (Chrome) |
Example: Background Sync for retry
// In page - register background sync
async function sendMessage(message) {
try {
// Try sending immediately
await fetch("/api/messages", {
"method": "POST",
"body": JSON.stringify(message)
});
console.log("Message sent");
} catch (error) {
// Failed - save to IndexedDB
await saveMessageForLater(message);
// Register sync to retry when online
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-messages");
console.log("Background sync registered");
}
}
async function saveMessageForLater(message) {
const db = await openDatabase();
const tx = db.transaction("pending", "readwrite");
await tx.objectStore("pending").add(message);
}
// In service worker - handle sync event
self.addEventListener("sync", (event) => {
console.log("Sync event:", event.tag);
if (event.tag === "sync-messages") {
event.waitUntil(syncMessages());
}
});
async function syncMessages() {
const db = await openDatabase();
const messages = await db.getAll("pending");
console.log(`Syncing ${messages.length} pending messages`);
for (const message of messages) {
try {
await fetch("/api/messages", {
"method": "POST",
"body": JSON.stringify(message)
});
// Success - remove from pending
await db.delete("pending", message.id);
console.log("Message synced:", message.id);
} catch (error) {
// Still offline or error - will retry on next sync
console.error("Sync failed:", error);
throw error; // Retry sync later
}
}
// Notify page
await notifyAllClients({
"type": "SYNC_COMPLETE",
"count": messages.length
});
}
// Check for pending syncs
async function checkPendingSyncs() {
const registration = await navigator.serviceWorker.ready;
const tags = await registration.sync.getTags();
console.log("Pending syncs:", tags);
}
Example: Periodic Background Sync
// Register periodic sync (requires installed PWA)
async function registerPeriodicSync() {
try {
const registration = await navigator.serviceWorker.ready;
// Check permission
const status = await navigator.permissions.query({
"name": "periodic-background-sync"
});
if (status.state === "granted") {
// Register periodic sync (every 24 hours minimum)
await registration.periodicSync.register("content-sync", {
"minInterval": 24 * 60 * 60 * 1000 // 24 hours in ms
});
console.log("Periodic sync registered");
}
} catch (error) {
console.error("Periodic sync failed:", error);
}
}
// List periodic syncs
async function listPeriodicSyncs() {
const registration = await navigator.serviceWorker.ready;
const tags = await registration.periodicSync.getTags();
console.log("Periodic syncs:", tags);
}
// Unregister periodic sync
async function unregisterPeriodicSync(tag) {
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.unregister(tag);
console.log("Unregistered:", tag);
}
// In service worker - handle periodic sync
self.addEventListener("periodicsync", (event) => {
console.log("Periodic sync event:", event.tag);
if (event.tag === "content-sync") {
event.waitUntil(syncContent());
}
});
async function syncContent() {
try {
// Fetch fresh content
const response = await fetch("/api/content");
const data = await response.json();
// Update cache
const cache = await caches.open("content-cache");
await cache.put("/api/content", new Response(JSON.stringify(data)));
console.log("Content synced");
// Notify clients
await notifyAllClients({
"type": "CONTENT_UPDATED",
"timestamp": Date.now()
});
} catch (error) {
console.error("Sync failed:", error);
}
}
Note: Background Sync has limited browser support (Chrome, Edge). Periodic Sync requires installed PWA and permission. Browser controls actual sync timing -
minInterval is minimum, not guaranteed. Sync events retry automatically if promise rejects.
Warning: Don't rely on Background Sync for critical operations - not supported everywhere. Periodic Sync can be delayed/skipped by browser to save battery. Always provide fallback (manual refresh button). Test offline scenarios thoroughly.
4. Push API (PushManager.subscribe, showNotification)
| Method | Description | Browser Support |
|---|---|---|
| registration.pushManager.subscribe(options) | Subscribes to push notifications. Returns Promise<PushSubscription>. Requires permission. | All Modern Browsers |
| registration.pushManager.getSubscription() | Gets existing subscription. Returns Promise<PushSubscription | null>. | All Modern Browsers |
| subscription.unsubscribe() | Unsubscribes from push. Returns Promise<boolean>. | All Modern Browsers |
| Notification.requestPermission() | Requests notification permission. Returns Promise<"granted" | "denied" | "default">. | All Browsers |
| PushSubscription Property | Type | Description |
|---|---|---|
| endpoint | string |
Push service URL. Send push messages to this endpoint. |
| keys | object |
Encryption keys: p256dh (public key), auth (authentication secret). |
| expirationTime | number | null |
Subscription expiration timestamp. null if no expiration. |
Example: Subscribe to push notifications
// Request notification permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
console.log("Notification permission:", permission);
return permission === "granted";
}
// Subscribe to push
async function subscribeToPush() {
try {
// Check permission
if (Notification.permission !== "granted") {
const granted = await requestNotificationPermission();
if (!granted) {
console.log("Notification permission denied");
return;
}
}
// Get service worker registration
const registration = await navigator.serviceWorker.ready;
// Check existing subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// Subscribe (need VAPID public key from server)
subscription = await registration.pushManager.subscribe({
"userVisibleOnly": true,
"applicationServerKey": urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
console.log("Subscribed to push");
} else {
console.log("Already subscribed");
}
// Send subscription to server
await sendSubscriptionToServer(subscription);
return subscription;
} catch (error) {
console.error("Push subscription failed:", error);
}
}
// Convert VAPID key
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Send subscription to server
async function sendSubscriptionToServer(subscription) {
const response = await fetch("/api/subscribe", {
"method": "POST",
"headers": { "Content-Type": "application/json" },
"body": JSON.stringify(subscription)
});
if (response.ok) {
console.log("Subscription saved on server");
}
}
// Unsubscribe
async function unsubscribeFromPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
console.log("Unsubscribed from push");
// Notify server
await fetch("/api/unsubscribe", {
"method": "POST",
"body": JSON.stringify({ "endpoint": subscription.endpoint })
});
}
}
Example: Handle push events in service worker
// In service worker - listen for push events
self.addEventListener("push", (event) => {
console.log("Push received");
let data = {
"title": "New Notification",
"body": "You have a new message",
"icon": "/icon.png",
"badge": "/badge.png"
};
// Parse push data
if (event.data) {
data = event.data.json();
}
// Show notification
event.waitUntil(
self.registration.showNotification(data.title, {
"body": data.body,
"icon": data.icon,
"badge": data.badge,
"data": data.url,
"tag": data.tag || "default",
"requireInteraction": false,
"actions": [
{ "action": "open", "title": "Open" },
{ "action": "close", "title": "Close" }
]
})
);
});
// Handle notification click
self.addEventListener("notificationclick", (event) => {
console.log("Notification clicked:", event.action);
event.notification.close();
if (event.action === "open") {
// Open URL from notification data
event.waitUntil(
clients.openWindow(event.notification.data || "/")
);
} else if (event.action === "close") {
// Just close (default behavior)
} else {
// Default click (no action button)
event.waitUntil(
clients.openWindow(event.notification.data || "/")
);
}
});
// Handle notification close
self.addEventListener("notificationclose", (event) => {
console.log("Notification closed:", event.notification.tag);
// Track analytics
fetch("/api/analytics/notification-closed", {
"method": "POST",
"body": JSON.stringify({
"tag": event.notification.tag,
"timestamp": Date.now()
})
});
});
Note: Push API requires HTTPS and service worker. Need VAPID keys from server for
applicationServerKey. Must show notification when push received (userVisibleOnly: true). Subscription can expire - check expirationTime and resubscribe.
Warning: Always request permission with user gesture (button click). Don't spam notifications - users will block. Test notification display on different platforms - varies by OS. Handle subscription expiration and errors gracefully. Safari has different push implementation (APNs).
5. Background Fetch API
| Method | Description | Browser Support |
|---|---|---|
| registration.backgroundFetch.fetch(id, requests, options) | Starts background fetch. Returns Promise<BackgroundFetchRegistration>. | Limited (Chrome) |
| registration.backgroundFetch.get(id) | Gets background fetch by ID. Returns Promise<BackgroundFetchRegistration>. | Limited (Chrome) |
| registration.backgroundFetch.getIds() | Gets all background fetch IDs. Returns Promise<string[]>. | Limited (Chrome) |
Example: Background fetch for offline downloads
// Start background fetch
async function downloadFiles(urls) {
try {
const registration = await navigator.serviceWorker.ready;
const bgFetch = await registration.backgroundFetch.fetch(
"download-videos",
urls,
{
"title": "Downloading videos",
"icons": [{ "src": "/icon.png", "sizes": "192x192" }],
"downloadTotal": 50 * 1024 * 1024 // 50 MB estimate
}
);
console.log("Background fetch started:", bgFetch.id);
// Listen for progress
bgFetch.addEventListener("progress", () => {
const percent = (bgFetch.downloaded / bgFetch.downloadTotal) * 100;
console.log(`Progress: ${percent.toFixed(2)}%`);
});
return bgFetch;
} catch (error) {
console.error("Background fetch failed:", error);
}
}
// Check background fetch status
async function checkDownloadStatus(id) {
const registration = await navigator.serviceWorker.ready;
const bgFetch = await registration.backgroundFetch.get(id);
if (bgFetch) {
console.log(`Downloaded: ${bgFetch.downloaded} / ${bgFetch.downloadTotal}`);
console.log(`Result: ${bgFetch.result}`); // "", "success", or "failure"
console.log(`Failure reason: ${bgFetch.failureReason}`);
}
}
// In service worker - handle background fetch events
self.addEventListener("backgroundfetchsuccess", (event) => {
console.log("Background fetch succeeded:", event.registration.id);
event.waitUntil(async function() {
// Get downloaded files
const records = await event.registration.matchAll();
const files = await Promise.all(
records.map(async (record) => {
const response = await record.responseReady;
const blob = await response.blob();
return { "url": record.request.url, "blob": blob };
})
);
// Cache downloaded files
const cache = await caches.open("downloads");
for (const file of files) {
await cache.put(file.url, new Response(file.blob));
}
// Update UI badge
await event.updateUI({ "title": "Download complete!" });
// Show notification
await self.registration.showNotification("Download Complete", {
"body": `${files.length} file(s) downloaded`,
"icon": "/icon.png"
});
}());
});
self.addEventListener("backgroundfetchfail", (event) => {
console.log("Background fetch failed:", event.registration.id);
event.waitUntil(
self.registration.showNotification("Download Failed", {
"body": "Please try again later",
"icon": "/icon.png"
})
);
});
self.addEventListener("backgroundfetchabort", (event) => {
console.log("Background fetch aborted:", event.registration.id);
});
self.addEventListener("backgroundfetchclick", (event) => {
console.log("Background fetch UI clicked:", event.registration.id);
event.waitUntil(
clients.openWindow("/downloads")
);
});
Note: Background Fetch has very limited support (Chrome only). Good for large file downloads that continue even if page closed. Browser shows download UI to user. Files available after download completes via
backgroundfetchsuccess event.
6. Workbox Strategies
| Workbox Strategy | Description | Use Case |
|---|---|---|
| NetworkFirst | Network request first, fallback to cache if offline. | Dynamic content, API responses |
| CacheFirst | Cache first, fallback to network if missing. | Static assets, fonts, images |
| StaleWhileRevalidate | Return cache immediately, update cache in background. | Frequently updated content |
| NetworkOnly | Always fetch from network, no caching. | Analytics, real-time data |
| CacheOnly | Only use cache, never network. | Pre-cached app shell |
Example: Workbox setup
// Import Workbox (in service worker)
importScripts("https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js");
if (workbox) {
console.log("Workbox loaded");
// Precache static assets
workbox.precaching.precacheAndRoute([
{ "url": "/", "revision": "v1" },
{ "url": "/styles.css", "revision": "v1" },
{ "url": "/app.js", "revision": "v1" }
]);
// Cache images - CacheFirst
workbox.routing.registerRoute(
({ request }) => request.destination === "image",
new workbox.strategies.CacheFirst({
"cacheName": "images",
"plugins": [
new workbox.expiration.ExpirationPlugin({
"maxEntries": 60,
"maxAgeSeconds": 30 * 24 * 60 * 60 // 30 days
})
]
})
);
// Cache CSS/JS - StaleWhileRevalidate
workbox.routing.registerRoute(
({ request }) =>
request.destination === "style" ||
request.destination === "script",
new workbox.strategies.StaleWhileRevalidate({
"cacheName": "static-resources"
})
);
// API calls - NetworkFirst
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new workbox.strategies.NetworkFirst({
"cacheName": "api-cache",
"plugins": [
new workbox.expiration.ExpirationPlugin({
"maxEntries": 50,
"maxAgeSeconds": 5 * 60 // 5 minutes
})
]
})
);
// Google Fonts - CacheFirst
workbox.routing.registerRoute(
({ url }) => url.origin === "https://fonts.googleapis.com",
new workbox.strategies.StaleWhileRevalidate({
"cacheName": "google-fonts-stylesheets"
})
);
workbox.routing.registerRoute(
({ url }) => url.origin === "https://fonts.gstatic.com",
new workbox.strategies.CacheFirst({
"cacheName": "google-fonts-webfonts",
"plugins": [
new workbox.cacheableResponse.CacheableResponsePlugin({
"statuses": [0, 200]
}),
new workbox.expiration.ExpirationPlugin({
"maxAgeSeconds": 60 * 60 * 24 * 365, // 1 year
"maxEntries": 30
})
]
})
);
// Offline fallback
workbox.routing.setCatchHandler(({ event }) => {
if (event.request.destination === "document") {
return caches.match("/offline.html");
}
return Response.error();
});
} else {
console.log("Workbox failed to load");
}
Note: Workbox simplifies service worker development with built-in strategies and plugins. Use
workbox-cli to generate precache manifest. Workbox handles common patterns: caching, routing, expiration, background sync. Consider bundle size when using Workbox - can use module imports instead of CDN.
Service Worker Best Practices
- Always use versioned cache names - easier to manage updates and cleanup
- Use
skipWaiting()andclients.claim()for immediate activation - Implement proper fetch strategies - CacheFirst for static, NetworkFirst for dynamic
- Always include offline fallback page in precache
- Test service worker updates - old workers can persist and cause bugs
- Use
event.waitUntil()to extend event lifetime for async operations - Don't cache authenticated content unless intentional - privacy/security risk
- Implement Background Sync for offline form submissions and retries
- Request notification permission with clear explanation and user gesture
- Handle push subscription expiration - resubscribe when needed
- Use Workbox for production - handles edge cases and best practices
- Monitor service worker errors with analytics - hard to debug in production