Building Offline-First Web Apps with Service Workers and IndexedDB
Users expect apps to work everywhere — on airplanes, in subway tunnels, and in areas with spotty coverage. Offline-first web development treats the network as an enhancement rather than a requirement. Therefore, your application works without connectivity and syncs when the network returns. This guide shows you how to build offline-first apps that actually work in production.
Why Offline-First? The Real-World Case
Consider a field service app used by technicians who work in basements, construction sites, and rural areas. They need to look up equipment manuals, fill out inspection forms, and capture photos — all while offline. An online-only app is useless for them. Moreover, even in connected environments, offline-first apps feel faster because they serve cached data instantly while fetching updates in the background.
The same pattern benefits consumer apps: a note-taking app that saves locally first (so you never lose work), a news reader that pre-caches articles for your commute, or an e-commerce app that lets you browse your cart on the subway.
Service Workers: The Network Proxy
A service worker sits between your app and the network, intercepting every request and deciding whether to serve from cache, fetch from network, or both. It runs in a separate thread and persists even after the user closes the tab.
// sw.js — Service Worker with cache-first strategy
const CACHE_NAME = 'app-v3';
const STATIC_ASSETS = [
'/',
'/index.html',
'/app.js',
'/styles.css',
'/manifest.json'
];
// Install: cache static assets
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting()) // Activate immediately
);
});
// Activate: clean up old caches
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
)
).then(() => self.clients.claim()) // Take control of all pages
);
});
// Fetch: Network-first for API, Cache-first for assets
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// Network-first: try network, fall back to cache
event.respondWith(
fetch(event.request)
.then(response => {
// Cache successful API responses for offline use
const clone = response.clone();
caches.open(CACHE_NAME).then(cache =>
cache.put(event.request, clone)
);
return response;
})
.catch(() => caches.match(event.request)) // Offline: use cached response
);
} else {
// Cache-first: serve from cache, update in background
event.respondWith(
caches.match(event.request)
.then(cached => {
const fetchPromise = fetch(event.request).then(response => {
caches.open(CACHE_NAME).then(cache =>
cache.put(event.request, response.clone())
);
return response;
});
return cached || fetchPromise;
})
);
}
});IndexedDB: Your Offline Database
IndexedDB is a full database in the browser that persists across sessions. Unlike localStorage (which only stores strings and has a 5MB limit), IndexedDB stores structured data, supports indexes and queries, and can hold hundreds of megabytes. Additionally, it’s transactional — writes either fully succeed or fully roll back.
// Simplified IndexedDB wrapper for offline data
class OfflineStore {
constructor(dbName, version = 1) {
this.dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('records')) {
const store = db.createObjectStore('records', { keyPath: 'id' });
store.createIndex('synced', 'synced', { unique: false });
store.createIndex('updated', 'updatedAt', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async save(record) {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction('records', 'readwrite');
tx.objectStore('records').put({
...record,
updatedAt: Date.now(),
synced: false // Mark as needing sync
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async getUnsynced() {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction('records', 'readonly');
const index = tx.objectStore('records').index('synced');
const request = index.getAll(false); // All unsynced records
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
// Usage
const store = new OfflineStore('my-app');
await store.save({ id: 'task-1', title: 'Fix bug', status: 'done' });
// Data is saved locally — works offline immediatelySync When Back Online
The Background Sync API lets your service worker defer network operations until connectivity returns. When the user submits a form offline, the data saves to IndexedDB and a sync event is registered. When the device reconnects, the service worker fires the sync handler which sends all pending changes to the server.
Conflict resolution is the hard part. When two devices modify the same record offline, you need a strategy: last-write-wins (simplest), merge fields (more complex), or operational transforms (most accurate but hardest). For most applications, last-write-wins with a visible “last modified” timestamp is sufficient.
When to Build Offline-First
Offline-first is worth the complexity for: field service applications, note-taking and productivity tools, e-commerce product browsing, data collection in low-connectivity environments, and any app where data loss is unacceptable. However, it adds significant complexity — don’t build offline-first for applications that genuinely require real-time connectivity (live trading, multiplayer games, video conferencing).
Related Reading:
Resources:
In conclusion, offline-first development treats connectivity as an enhancement, not a requirement. Start with service worker caching for static assets, add IndexedDB for data persistence, and implement background sync for seamless reconnection. Your users will thank you the next time they’re in an elevator.