Offline-First PWAs with Sync
PWAs that work offline provide the best user experience, especially on unreliable networks. This is achieved through service workers and intelligent sync strategies.
Service Workers Fundamentals
A service worker is a JavaScript worker that intercepts network requests and caches responses.
// service-worker.js
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js',
])
})
)
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request)
})
)
})
Flow:
- Browser makes request.
- Service worker intercepts it.
- Check cache first. If hit, return cached response.
- If miss, fetch from network and cache result.
- Return response to page.
Caching Strategies
Cache first, network fallback:
// Strategy: Offline pages from cache, fall back to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request)
})
)
})
Network first, cache fallback:
// Strategy: Try network, fall back to cache if offline
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
if (!response.ok) throw new Error('Network failed')
return caches.open('v1').then((cache) => {
cache.put(event.request, response.clone())
return response
})
})
.catch(() => caches.match(event.request))
)
})
Stale-while-revalidate:
// Strategy: Serve cache immediately, fetch fresh in background
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
const fetchPromise = fetch(event.request).then((response) => {
if (response.ok) {
caches.open('v1').then((cache) => cache.put(event.request, response.clone()))
}
return response
})
return cachedResponse || fetchPromise
})
)
})
Choose strategy based on content type:
- HTML (pages): Network first. Users want fresh content.
- CSS/JS (assets): Cache first. These rarely change.
- Images: Stale-while-revalidate. Load fast, refresh in background.
- API calls: Network first for real-time data, cache fallback when offline.
Background Sync
Send data to server when connection returns:
// Register sync
navigator.serviceWorker.ready.then((registration) => {
registration.sync.register('sync-data')
})
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(
// Fetch pending data from IndexedDB and send to server
getOfflineQueue()
.then((queue) => sendToServer(queue))
.then(() => clearOfflineQueue())
.catch(() => {
// Retry later if network fails
throw new Error('Sync failed, will retry')
})
)
}
})
Browser automatically retries sync when connection is restored. Timeout and retry logic are handled by the browser.
Local Storage with IndexedDB
Service workers can't access localStorage (synchronous). Use IndexedDB for offline data:
// Store offline data
function saveOfflineData(data) {
return new Promise((resolve, reject) => {
const request = indexedDB.open('app-db', 1)
request.onsuccess = () => {
const db = request.result
const tx = db.transaction('offline', 'readwrite')
const store = tx.objectStore('offline')
store.add(data)
tx.oncomplete = resolve
tx.onerror = reject
}
})
}
// Retrieve offline data
function getOfflineQueue() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('app-db', 1)
request.onsuccess = () => {
const db = request.result
const tx = db.transaction('offline', 'readonly')
const store = tx.objectStore('offline')
const all = store.getAll()
all.onsuccess = () => resolve(all.result)
}
})
}
Offline Data Submission
Flow for form submission when offline:
- User submits form.
form.addEventListener('submit', async (e) => {
e.preventDefault()
const formData = new FormData(form)
const data = Object.fromEntries(formData)
try {
// Try to submit
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data),
})
if (response.ok) {
showSuccess('Submitted!')
}
} catch (e) {
// Offline: store for later sync
await saveOfflineData(data)
showMessage('Offline. Will submit when connection returns.')
}
})
- Service worker syncs when online.
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(getOfflineQueue().then(sendAll))
}
})
async function sendAll(queue) {
for (const item of queue) {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(item),
})
if (response.ok) {
removeFromQueue(item.id)
}
}
}
UI Indicators
Show users when data is syncing:
const [syncing, setSyncing] = useState(false)
// Detect sync status
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'SYNC_START') setSyncing(true)
if (event.data.type === 'SYNC_END') setSyncing(false)
})
}
return (
<div>
{syncing && <p>Syncing data...</p>}
<Content />
</div>
)
Debugging
Chrome DevTools:
- Open DevTools → Application tab.
- Service Workers section shows registered workers.
- Cache Storage shows cached assets.
- Storage > IndexedDB shows stored data.
Test offline:
- DevTools → Network tab.
- Offline checkbox → enables offline mode.
- App should continue functioning.
Real-World Example
A delivery app:
- Driver goes offline while taking orders.
- Orders stored in IndexedDB.
- Driver continues taking orders seamlessly.
- Driver regains connectivity.
- Background sync triggers, uploads all orders.
- Driver sees "Synced" notification.
Result: Seamless experience, no data loss, offline-first by design.
Frequently asked questions
What's the difference between cache-first and network-first?
Cache-first serves instantly from cache; network updates in background. Network-first tries network first for fresh data. Use based on content freshness needs.
Can service workers send data to server when the browser closes?
Not instantly, but background sync queues requests. When the browser reopens and connectivity returns, sync completes automatically.
How much data can IndexedDB store?
Typically 50-100MB depending on browser and device. Always check available quota before storing large amounts.