Back to Blog
PWAService WorkerOfflineWeb Development

Progressive Web Apps: Building Native-Like Experiences

Build modern Progressive Web Apps. Learn service workers, app manifest, offline support, and native device features.

B
Bootspring Team
Engineering
February 26, 2026
6 min read

Progressive Web Apps (PWAs) combine the reach of the web with native app capabilities. This guide covers building modern PWAs with offline support and native features.

Core PWA Requirements#

Web App Manifest#

1// public/manifest.json 2{ 3 "name": "My Progressive App", 4 "short_name": "MyApp", 5 "description": "A fast, reliable web application", 6 "start_url": "/", 7 "display": "standalone", 8 "background_color": "#ffffff", 9 "theme_color": "#2563eb", 10 "orientation": "portrait-primary", 11 "icons": [ 12 { 13 "src": "/icons/icon-192.png", 14 "sizes": "192x192", 15 "type": "image/png", 16 "purpose": "any maskable" 17 }, 18 { 19 "src": "/icons/icon-512.png", 20 "sizes": "512x512", 21 "type": "image/png" 22 } 23 ], 24 "screenshots": [ 25 { 26 "src": "/screenshots/desktop.png", 27 "sizes": "1280x720", 28 "type": "image/png", 29 "form_factor": "wide" 30 }, 31 { 32 "src": "/screenshots/mobile.png", 33 "sizes": "750x1334", 34 "type": "image/png", 35 "form_factor": "narrow" 36 } 37 ], 38 "shortcuts": [ 39 { 40 "name": "New Task", 41 "url": "/tasks/new", 42 "icons": [{ "src": "/icons/new-task.png", "sizes": "96x96" }] 43 } 44 ], 45 "share_target": { 46 "action": "/share", 47 "method": "POST", 48 "enctype": "multipart/form-data", 49 "params": { 50 "title": "title", 51 "text": "text", 52 "url": "url" 53 } 54 } 55}

Service Worker Registration#

1// app/layout.tsx 2'use client'; 3 4import { useEffect } from 'react'; 5 6export function ServiceWorkerRegistration() { 7 useEffect(() => { 8 if ('serviceWorker' in navigator) { 9 navigator.serviceWorker 10 .register('/sw.js') 11 .then((registration) => { 12 console.log('SW registered:', registration.scope); 13 14 // Check for updates 15 registration.addEventListener('updatefound', () => { 16 const newWorker = registration.installing; 17 newWorker?.addEventListener('statechange', () => { 18 if (newWorker.state === 'installed' && 19 navigator.serviceWorker.controller) { 20 // New version available 21 showUpdateNotification(); 22 } 23 }); 24 }); 25 }) 26 .catch((error) => { 27 console.error('SW registration failed:', error); 28 }); 29 } 30 }, []); 31 32 return null; 33}

Service Worker Implementation#

Caching Strategies#

1// public/sw.js 2const CACHE_NAME = 'app-v1'; 3const STATIC_ASSETS = [ 4 '/', 5 '/offline', 6 '/styles/main.css', 7 '/scripts/app.js', 8]; 9 10// Install: Cache static assets 11self.addEventListener('install', (event) => { 12 event.waitUntil( 13 caches.open(CACHE_NAME).then((cache) => { 14 return cache.addAll(STATIC_ASSETS); 15 }) 16 ); 17 self.skipWaiting(); 18}); 19 20// Activate: Clean old caches 21self.addEventListener('activate', (event) => { 22 event.waitUntil( 23 caches.keys().then((keys) => { 24 return Promise.all( 25 keys 26 .filter((key) => key !== CACHE_NAME) 27 .map((key) => caches.delete(key)) 28 ); 29 }) 30 ); 31 self.clients.claim(); 32}); 33 34// Fetch: Handle requests 35self.addEventListener('fetch', (event) => { 36 const { request } = event; 37 const url = new URL(request.url); 38 39 // API requests: Network first 40 if (url.pathname.startsWith('/api/')) { 41 event.respondWith(networkFirst(request)); 42 return; 43 } 44 45 // Static assets: Cache first 46 if (request.destination === 'image' || 47 request.destination === 'style' || 48 request.destination === 'script') { 49 event.respondWith(cacheFirst(request)); 50 return; 51 } 52 53 // HTML: Stale while revalidate 54 event.respondWith(staleWhileRevalidate(request)); 55}); 56 57async function cacheFirst(request) { 58 const cached = await caches.match(request); 59 if (cached) return cached; 60 61 const response = await fetch(request); 62 const cache = await caches.open(CACHE_NAME); 63 cache.put(request, response.clone()); 64 return response; 65} 66 67async function networkFirst(request) { 68 try { 69 const response = await fetch(request); 70 const cache = await caches.open(CACHE_NAME); 71 cache.put(request, response.clone()); 72 return response; 73 } catch (error) { 74 const cached = await caches.match(request); 75 return cached || new Response('Offline', { status: 503 }); 76 } 77} 78 79async function staleWhileRevalidate(request) { 80 const cache = await caches.open(CACHE_NAME); 81 const cached = await cache.match(request); 82 83 const fetchPromise = fetch(request).then((response) => { 84 cache.put(request, response.clone()); 85 return response; 86 }).catch(() => cached || caches.match('/offline')); 87 88 return cached || fetchPromise; 89}

Push Notifications#

Subscribing to Push#

1async function subscribeToPush() { 2 const registration = await navigator.serviceWorker.ready; 3 4 const subscription = await registration.pushManager.subscribe({ 5 userVisibleOnly: true, 6 applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), 7 }); 8 9 // Send subscription to server 10 await fetch('/api/push/subscribe', { 11 method: 'POST', 12 headers: { 'Content-Type': 'application/json' }, 13 body: JSON.stringify(subscription), 14 }); 15 16 return subscription; 17} 18 19function urlBase64ToUint8Array(base64String: string) { 20 const padding = '='.repeat((4 - (base64String.length % 4)) % 4); 21 const base64 = (base64String + padding) 22 .replace(/-/g, '+') 23 .replace(/_/g, '/'); 24 const rawData = window.atob(base64); 25 return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); 26}

Handling Push Events#

1// sw.js 2self.addEventListener('push', (event) => { 3 const data = event.data?.json() || {}; 4 5 const options = { 6 body: data.body, 7 icon: '/icons/notification.png', 8 badge: '/icons/badge.png', 9 vibrate: [100, 50, 100], 10 data: { 11 url: data.url || '/', 12 }, 13 actions: [ 14 { action: 'view', title: 'View' }, 15 { action: 'dismiss', title: 'Dismiss' }, 16 ], 17 }; 18 19 event.waitUntil( 20 self.registration.showNotification(data.title || 'Notification', options) 21 ); 22}); 23 24self.addEventListener('notificationclick', (event) => { 25 event.notification.close(); 26 27 if (event.action === 'view' || !event.action) { 28 const url = event.notification.data.url; 29 event.waitUntil( 30 clients.matchAll({ type: 'window' }).then((windowClients) => { 31 // Focus existing window or open new 32 for (const client of windowClients) { 33 if (client.url === url && 'focus' in client) { 34 return client.focus(); 35 } 36 } 37 return clients.openWindow(url); 38 }) 39 ); 40 } 41});

Background Sync#

1// Register background sync 2async function saveForLater(data) { 3 // Store in IndexedDB 4 await idb.put('pendingSync', data); 5 6 // Register sync 7 const registration = await navigator.serviceWorker.ready; 8 await registration.sync.register('sync-data'); 9} 10 11// Handle sync in service worker 12self.addEventListener('sync', (event) => { 13 if (event.tag === 'sync-data') { 14 event.waitUntil(syncPendingData()); 15 } 16}); 17 18async function syncPendingData() { 19 const pending = await idb.getAll('pendingSync'); 20 21 for (const item of pending) { 22 try { 23 await fetch('/api/sync', { 24 method: 'POST', 25 body: JSON.stringify(item), 26 }); 27 await idb.delete('pendingSync', item.id); 28 } catch (error) { 29 // Will retry next time 30 throw error; 31 } 32 } 33}

Native Features#

Share API#

1async function shareContent(data: ShareData) { 2 if (navigator.canShare?.(data)) { 3 try { 4 await navigator.share(data); 5 return true; 6 } catch (error) { 7 if (error.name !== 'AbortError') { 8 console.error('Share failed:', error); 9 } 10 } 11 } 12 return false; 13} 14 15// Usage 16shareContent({ 17 title: 'Check this out', 18 text: 'Amazing content', 19 url: window.location.href, 20});

File System Access#

1async function saveFile(content: string, filename: string) { 2 if ('showSaveFilePicker' in window) { 3 const handle = await window.showSaveFilePicker({ 4 suggestedName: filename, 5 types: [{ 6 description: 'Text files', 7 accept: { 'text/plain': ['.txt'] }, 8 }], 9 }); 10 11 const writable = await handle.createWritable(); 12 await writable.write(content); 13 await writable.close(); 14 } else { 15 // Fallback to download 16 const blob = new Blob([content], { type: 'text/plain' }); 17 const url = URL.createObjectURL(blob); 18 const a = document.createElement('a'); 19 a.href = url; 20 a.download = filename; 21 a.click(); 22 URL.revokeObjectURL(url); 23 } 24}

Install Prompt#

1import { useState, useEffect } from 'react'; 2 3export function InstallPrompt() { 4 const [deferredPrompt, setDeferredPrompt] = useState<any>(null); 5 const [showPrompt, setShowPrompt] = useState(false); 6 7 useEffect(() => { 8 const handler = (e: Event) => { 9 e.preventDefault(); 10 setDeferredPrompt(e); 11 setShowPrompt(true); 12 }; 13 14 window.addEventListener('beforeinstallprompt', handler); 15 return () => window.removeEventListener('beforeinstallprompt', handler); 16 }, []); 17 18 const handleInstall = async () => { 19 if (!deferredPrompt) return; 20 21 deferredPrompt.prompt(); 22 const { outcome } = await deferredPrompt.userChoice; 23 24 if (outcome === 'accepted') { 25 setShowPrompt(false); 26 } 27 setDeferredPrompt(null); 28 }; 29 30 if (!showPrompt) return null; 31 32 return ( 33 <div className="fixed bottom-4 left-4 right-4 bg-white shadow-lg rounded-lg p-4"> 34 <p className="font-medium">Install our app for the best experience</p> 35 <div className="mt-2 flex gap-2"> 36 <button onClick={handleInstall} className="btn-primary"> 37 Install 38 </button> 39 <button onClick={() => setShowPrompt(false)} className="btn-secondary"> 40 Not now 41 </button> 42 </div> 43 </div> 44 ); 45}

Best Practices#

  1. Test offline mode: Ensure core functionality works offline
  2. Optimize asset caching: Cache only what's needed
  3. Handle update gracefully: Notify users of new versions
  4. Provide feedback: Show sync status and offline indicators
  5. Respect battery/data: Use background sync sparingly

Conclusion#

PWAs provide native-like experiences on the web. Start with the basics—manifest and service worker—then progressively add features like push notifications and background sync.

Share this article

Help spread the word about Bootspring