Back to Blog
Service WorkersPWAOfflineCaching

Service Workers: Building Offline-First Applications

Implement service workers for offline functionality. Learn caching strategies, background sync, and push notifications.

B
Bootspring Team
Engineering
February 27, 2026
3 min read

Service workers enable offline functionality, background sync, and push notifications.

Registering a Service Worker#

1// main.js 2if ('serviceWorker' in navigator) { 3 window.addEventListener('load', async () => { 4 try { 5 const registration = await navigator.serviceWorker.register('/sw.js'); 6 console.log('SW registered:', registration.scope); 7 } catch (error) { 8 console.log('SW registration failed:', error); 9 } 10 }); 11}

Basic Service Worker#

1// sw.js 2const CACHE_NAME = 'app-v1'; 3const ASSETS = [ 4 '/', 5 '/index.html', 6 '/styles.css', 7 '/app.js', 8 '/offline.html', 9]; 10 11// Install - cache assets 12self.addEventListener('install', (event) => { 13 event.waitUntil( 14 caches.open(CACHE_NAME).then((cache) => { 15 return cache.addAll(ASSETS); 16 }) 17 ); 18 self.skipWaiting(); 19}); 20 21// Activate - clean old caches 22self.addEventListener('activate', (event) => { 23 event.waitUntil( 24 caches.keys().then((keys) => { 25 return Promise.all( 26 keys 27 .filter((key) => key !== CACHE_NAME) 28 .map((key) => caches.delete(key)) 29 ); 30 }) 31 ); 32 self.clients.claim(); 33});

Caching Strategies#

Cache First (Static Assets)#

1self.addEventListener('fetch', (event) => { 2 event.respondWith( 3 caches.match(event.request).then((cached) => { 4 return cached || fetch(event.request); 5 }) 6 ); 7});

Network First (API Requests)#

1self.addEventListener('fetch', (event) => { 2 if (event.request.url.includes('/api/')) { 3 event.respondWith( 4 fetch(event.request) 5 .then((response) => { 6 const clone = response.clone(); 7 caches.open(CACHE_NAME).then((cache) => { 8 cache.put(event.request, clone); 9 }); 10 return response; 11 }) 12 .catch(() => caches.match(event.request)) 13 ); 14 } 15});

Stale While Revalidate#

1self.addEventListener('fetch', (event) => { 2 event.respondWith( 3 caches.open(CACHE_NAME).then((cache) => { 4 return cache.match(event.request).then((cached) => { 5 const fetchPromise = fetch(event.request).then((response) => { 6 cache.put(event.request, response.clone()); 7 return response; 8 }); 9 return cached || fetchPromise; 10 }); 11 }) 12 ); 13});

Complete Strategy Router#

1const CACHE_NAME = 'app-v1'; 2 3self.addEventListener('fetch', (event) => { 4 const { request } = event; 5 const url = new URL(request.url); 6 7 // Static assets - cache first 8 if (request.destination === 'style' || 9 request.destination === 'script' || 10 request.destination === 'image') { 11 event.respondWith(cacheFirst(request)); 12 return; 13 } 14 15 // API requests - network first 16 if (url.pathname.startsWith('/api/')) { 17 event.respondWith(networkFirst(request)); 18 return; 19 } 20 21 // HTML pages - stale while revalidate 22 if (request.mode === 'navigate') { 23 event.respondWith(staleWhileRevalidate(request)); 24 return; 25 } 26}); 27 28async function cacheFirst(request) { 29 const cached = await caches.match(request); 30 return cached || fetch(request); 31} 32 33async function networkFirst(request) { 34 try { 35 const response = await fetch(request); 36 const cache = await caches.open(CACHE_NAME); 37 cache.put(request, response.clone()); 38 return response; 39 } catch { 40 return caches.match(request); 41 } 42} 43 44async function staleWhileRevalidate(request) { 45 const cache = await caches.open(CACHE_NAME); 46 const cached = await cache.match(request); 47 48 const fetchPromise = fetch(request).then((response) => { 49 cache.put(request, response.clone()); 50 return response; 51 }); 52 53 return cached || fetchPromise; 54}

Background Sync#

1// In your app 2async function saveData(data) { 3 try { 4 await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) }); 5 } catch { 6 // Queue for background sync 7 const registration = await navigator.serviceWorker.ready; 8 await registration.sync.register('sync-data'); 9 10 // Store data in IndexedDB for later 11 await saveToIndexedDB('pending-sync', data); 12 } 13} 14 15// In service worker 16self.addEventListener('sync', (event) => { 17 if (event.tag === 'sync-data') { 18 event.waitUntil(syncPendingData()); 19 } 20}); 21 22async function syncPendingData() { 23 const pending = await getFromIndexedDB('pending-sync'); 24 25 for (const data of pending) { 26 await fetch('/api/save', { method: 'POST', body: JSON.stringify(data) }); 27 await removeFromIndexedDB('pending-sync', data.id); 28 } 29}

Push Notifications#

1// Subscribe to push 2async function subscribeToPush() { 3 const registration = await navigator.serviceWorker.ready; 4 5 const subscription = await registration.pushManager.subscribe({ 6 userVisibleOnly: true, 7 applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), 8 }); 9 10 await fetch('/api/push/subscribe', { 11 method: 'POST', 12 body: JSON.stringify(subscription), 13 }); 14} 15 16// In service worker 17self.addEventListener('push', (event) => { 18 const data = event.data.json(); 19 20 event.waitUntil( 21 self.registration.showNotification(data.title, { 22 body: data.body, 23 icon: '/icon.png', 24 data: { url: data.url }, 25 }) 26 ); 27}); 28 29self.addEventListener('notificationclick', (event) => { 30 event.notification.close(); 31 event.waitUntil( 32 clients.openWindow(event.notification.data.url) 33 ); 34});

Service workers unlock powerful offline experiences with proper caching and background sync.

Share this article

Help spread the word about Bootspring