Building Offline-First Web Apps: A Practical Guide to IndexedDB and Service Workers
A deeply technical, code-heavy walkthrough on building resilient offline-first web applications using IndexedDB, Service Workers, cache strategies, and background synchronization.
Building Offline-First Web Apps: A Practical Guide to IndexedDB and Service Workers
Modern users expect web applications to behave like native applications. They should load instantly, respond immediately to user interactions, and—crucially—continue functioning even when network connectivity drops to zero. Designing for this reality requires a shift from a network-first to an offline-first architecture.
In an offline-first architecture, the local device is the primary source of truth. The network becomes an asynchronous enhancement rather than a hard dependency. To achieve this, we rely on two foundational pillars of modern Progressive Web Apps (PWAs): Service Workers for network interception and asset caching, and IndexedDB for structured client-side persistence.
In this guide, we will walk through the architecture and implementation of a truly resilient offline-first single-page application (SPA). We will cover dynamic caching strategies, transactional data storage with IndexedDB, and conflict-free background data synchronization.
1. Architectural Overview
An offline-first application decouples the UI from the network transport layer. The data flow follows a local-first paradigm:
- User Actions write directly to local storage (IndexedDB) for immediate UI feedback.
- Mutations are queued in an outbox store if the network is offline.
- Service Workers intercept HTTP requests, serving static assets from the Cache API and handling runtime API fallbacks.
- Background Sync or online event listeners flush the local mutation queue to the remote server once connectivity is restored.
+-------------------------------------------------------------+
| UI / SPA |
+--------------+------------------------------+---------------+
| |
Reads / Writes Dispatches Actions
v v
+--------------+--------------+ +----------+----------------+
| IndexedDB Store | | Service Worker / Outbox |
| (App State & Local Data) | | (Background Sync Queue) |
+-----------------------------+ +----------+----------------+
|
Network Requests (Fetch)
v
+----------+----------------+
| Remote Server |
+---------------------------+
2. Setting Up the Service Worker for Asset and API Caching
The Service Worker sits between your application and the network. It allows you to programmatically control how requests are handled. Let’s implement a robust Service Worker that implements a Stale-While-Revalidate strategy for static assets and a Network-First with Cache Fallback strategy for API data.
Create a file named sw.js in your root directory:
const CACHE_VERSION = 'v1.0.0';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const RUNTIME_CACHE = `runtime-${CACHE_VERSION}`;
const PRECACHE_URLS = [
'/',
'/index.html',
'/static/css/main.css',
'/static/js/main.js',
'/offline.html'
];
// Install Event: Cache critical static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// Activate Event: Clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== STATIC_CACHE && cacheName !== RUNTIME_CACHE) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
// Fetch Event: Implement caching strategies
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Handle API requests (Network First, falling back to cache)
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstWithCache(request));
return;
}
// Handle static assets (Stale While Revalidate)
event.respondWith(staleWhileRevalidate(request));
});
async function networkFirstWithCache(request) {
const cache = await caches.open(RUNTIME_CACHE);
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
// If both fail and it's a navigation request, return offline fallback
if (request.mode === 'navigate') {
return caches.match('/offline.html');
}
throw error;
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(STATIC_CACHE);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
}).catch(() => {
// Network failure is fine if we have a cache hit
});
return cachedResponse || fetchPromise;
}
Registering the Service Worker
In your main application entry point (e.g., index.js), register the Service Worker safely:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('ServiceWorker registered with scope:', registration.scope);
})
.catch((error) => {
console.error('ServiceWorker registration failed:', error);
});
});
}
3. Structured Local Persistence with IndexedDB
While the Cache API handles HTTP responses, complex application state and relational data require a robust NoSQL database in the browser: IndexedDB. Direct use of the IndexedDB API can be verbose due to its event-based paradigm. Using a lightweight wrapper like idb makes interacting with transactions clean and promise-based.
Install the wrapper via npm:
npm install idb
Initializing the Database and Stores
Let’s design a data access layer for an application managing tasks (todos) and a synchronization outbox (sync-queue):
import { openDB } from 'idb';
const DB_NAME = 'app-offline-db';
const DB_VERSION = 1;
export async function initDB() {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db, oldVersion, newVersion, transaction) {
// Store for application entities
if (!db.objectStoreNames.contains('todos')) {
const todoStore = db.createObjectStore('todos', { keyPath: 'id', autoIncrement: true });
todoStore.createIndex('by-status', 'completed');
todoStore.createIndex('by-updated', 'updatedAt');
}
// Store for offline mutation queue (Outbox Pattern)
if (!db.objectStoreNames.contains('sync-queue')) {
db.createObjectStore('sync-queue', { keyPath: 'id', autoIncrement: true });
}
},
});
}
Implementing Repository Methods
Now, build clean CRUD operations that write locally first:
export async function getTodos() {
const db = await initDB();
return db.getAll('todos');
}
export async function saveTodoLocally(todo) {
const db = await initDB();
const tx = db.transaction(['todos', 'sync-queue'], 'readwrite');
const enrichedTodo = {
...todo,
updatedAt: new Date().toISOString(),
synced: false
};
// 1. Save or update in main store
await tx.objectStore('todos').put(enrichedTodo);
// 2. Queue for synchronization
await tx.objectStore('sync-queue').add({
url: '/api/todos',
method: todo.id ? 'PUT' : 'POST',
payload: enrichedTodo,
timestamp: Date.now()
});
await tx.done;
return enrichedTodo;
}
4. Background Sync and Data Synchronization
When a user performs mutations offline, those actions live safely in our sync-queue. When connectivity returns, we need to flush this queue to the server. We can use two mechanisms: the native Background Sync API (where supported) and a fallback event listener for online/offline status changes.
Registering a Background Sync Task
Inside your Service Worker or application logic when a mutation occurs, request a background sync:
export async function triggerSync() {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
try {
await registration.sync.register('sync-todos');
console.log('Background sync registered successfully');
} catch (err) {
console.error('Background sync registration failed:', err);
}
} else {
// Fallback for browsers without Background Sync support
await flushSyncQueue();
}
}
Handling Sync in the Service Worker
In sw.js, listen for the sync event, pull items from the IndexedDB queue, and push them upstream:
// Import idb inside SW or use raw indexeddb operations
importScripts('https://cdn.jsdelivr.net/npm/idb@7/build/iife.js');
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-todos') {
event.waitUntil(flushSyncQueue());
}
});
async function flushSyncQueue() {
const db = await idb.openDB('app-offline-db', 1);
const tx = db.transaction('sync-queue', 'readwrite');
const store = tx.objectStore('sync-queue');
const items = await store.getAll();
for (const item of items) {
try {
const response = await fetch(item.url, {
method: item.method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(item.payload),
});
if (response.ok) {
// Successfully synced, remove from queue
await db.delete('sync-queue', item.id);
// Optionally update local record to marked as synced
const todoStore = db.transaction('todos', 'readwrite').objectStore('todos');
const localRecord = await todoStore.get(item.payload.id);
if (localRecord) {
localRecord.synced = true;
await todoStore.put(localRecord);
}
} else {
console.error('Server rejected sync item:', response.statusText);
}
} catch (error) {
console.error('Network error during sync, will retry later:', error);
throw error; // This causes the sync event to retry
}
}
}
5. Handling Connection State in the UI
An offline-first app must keep the user informed of their connectivity status and synchronization state. We can combine navigator.onLine with window event listeners to manage state reactively.
import { useEffect, useState } from 'react';
import { triggerSync } from './syncManager';
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [isSyncing, setIsSyncing] = useState(false);
useEffect(() => {
const handleOnline = async () => {
setIsOnline(true);
setIsSyncing(true);
try {
await triggerSync();
} finally {
setIsSyncing(false);
}
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return { isOnline, isSyncing };
}
6. Conflict Resolution Strategies
When multiple clients modify the same data offline, conflicts are inevitable. Designing an offline-first architecture requires a deliberate conflict resolution strategy:
- Last-Write-Wins (LWW): Compare timestamps (
updatedAt) on the client and server. The highest timestamp overwrites the other. Simple to implement, but vulnerable to clock skew and data loss. - Operational Transformation (OT) / Conflict-free Replicated Data Types (CRDTs): Ideal for collaborative editing applications where merge logic is deterministic.
- Server-Side Validation with User Prompting: If a conflict occurs, the server rejects the write with a
409 Conflictstatus code, returning the current server state so the client can present a manual diff merge UI to the user.
Conclusion
Building an offline-first web application requires abandoning the assumption of constant network availability. By combining Service Workers for precise caching layers, IndexedDB for durable client-side state, and the Outbox pattern for background synchronization, you can deliver resilient, lightning-fast web experiences that perform under any network condition.
Start small: implement structured caching in your Service Worker, introduce an IndexedDB persistence layer for your core entities, and gradually layer in background synchronization queues. Your users will appreciate the seamless reliability.