Building Resilient Offline-First React Apps: IndexedDB, Service Workers, and Sync Architecture
Most web applications are designed under the optimistic assumption that users enjoy continuous, high-speed internet connectivity. In reality, users navigate intermittent cellular dead zones, transit tunnels, congested conference Wi-Fi, and spotty mobile networks. When connectivity drops, traditional single-page applications freeze, display blank error screens, or lose uncommitted form inputs.
An Offline-First architecture fundamentally reorders this relationship. Instead of treating the remote server as the primary data store and the browser as an ephemeral viewer, the local client becomes the primary operational environment. Data reads resolve instantly from local storage, user mutations commit immediately to local queues, and synchronization with the remote backend happens opportunistically in the background.
In this deep dive, we explore how to architect production-grade offline-first React applications: leveraging IndexedDB for high-capacity client storage, using Service Workers for network interception, subscribing to connectivity state with useSyncExternalStore, and building an Outbox mutation queue with background synchronization.
Storage Architecture: Why IndexedDB Outperforms localStorage
Choosing the correct client-side storage engine is the foundational architectural decision for offline capability.
The Limitations of localStorage
- Synchronous Execution:
localStorageoperations run on the main browser thread. Reading or writing large datasets blocks user interface rendering and drops frames. - Strict Capacity Caps: Capped at ~5MB per origin, making it impossible to store media, offline documents, or rich datasets.
- String Only: Requires constant
JSON.stringify()andJSON.parse()serialization cycles.
The Power of IndexedDB
The IndexedDB API is an asynchronous, transactional, indexed object store:
- Asynchronous & Non-Blocking: Disk I/O happens in the background without stuttering React animations.
- Gigabyte Scale: Capable of storing hundreds of megabytes or gigabytes of structured data, depending on available disk space.
- Native Types & Blobs: Stores JavaScript objects, arrays, and binary Blobs directly without serialization.
- Compound Indexes & Transactions: Enables complex range queries, indexing, and atomic transactions.
Intercepting Network Requests with Service Workers
To ensure the React application shell loads without network access, the Service Worker API acts as a programmable network proxy.
// public/sw.js - Service Worker Cache Strategy
const STATIC_CACHE = 'static-v1';
const STATIC_ASSETS = ['/', '/index.html', '/manifest.json', '/bundle.js', '/styles.css'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Strategy 1: Cache-First for static assets
if (STATIC_ASSETS.includes(url.pathname)) {
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
return;
}
// Strategy 2: Stale-While-Revalidate for API reads
if (url.pathname.startsWith('/api/')) {
event.respondWith(
caches.open('api-cache').then(async (cache) => {
const cachedResponse = await cache.match(event.request);
const fetchPromise = fetch(event.request)
.then((networkResponse) => {
if (networkResponse.status === 200) {
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
})
.catch(() => cachedResponse); // Fallback to cache on network drop
return cachedResponse || fetchPromise;
})
);
}
});
By intercepting HTTP fetches, the service worker returns cached HTML, CSS, and JS instantaneously, allowing the React component tree to mount even in airplane mode.
Reactive Connectivity Tracking with useSyncExternalStore
To adapt UI components when offline (e.g., displaying an "Offline Mode" badge or disabling real-time collaboration features), React applications must track network connectivity.
Using useSyncExternalStore guarantees that all components subscribe to navigator.onLine without state tearing:
import { useSyncExternalStore } from 'react';
function subscribeOnlineStatus(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getOnlineSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // Default to true during SSR
}
export function useOnlineStatus(): boolean {
return useSyncExternalStore(
subscribeOnlineStatus,
getOnlineSnapshot,
getServerSnapshot
);
}
Components can now render conditionally based on active connectivity:
export function ConnectivityBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<aside className="offline-banner" role="status">
<span>You are currently offline. Changes are saved locally and will sync once reconnected.</span>
</aside>
);
}
The Outbox Mutation Queue Pattern
The core challenge of offline-first architecture is handling user mutations (creates, updates, deletes) when the server cannot be reached.
The standard solution is the Outbox Pattern:
- When a user submits an action, commit the change immediately to local IndexedDB storage.
- Simultaneously, append a mutation descriptor to an IndexedDB
outboxqueue with a unique timestamp and idempotency key. - Update the React UI optimistically.
- When connectivity resumes, an outbox worker flushes queued requests to the server in chronological order.
// lib/offlineQueue.ts
export interface QueuedMutation {
id: string;
endpoint: string;
method: 'POST' | 'PUT' | 'DELETE';
payload: unknown;
timestamp: number;
}
// Open or initialize IndexedDB
export function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open('AppOfflineDB', 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('outbox')) {
db.createObjectStore('outbox', { keyPath: 'id' });
}
if (!db.objectStoreNames.contains('documents')) {
db.createObjectStore('documents', { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Queue a mutation to the outbox
export async function queueMutation(mutation: QueuedMutation): Promise<void> {
const db = await openDatabase();
const tx = db.transaction('outbox', 'readwrite');
tx.objectStore('outbox').add(mutation);
await new Promise((res) => (tx.oncomplete = res));
// Request background synchronization if supported
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('flush-outbox');
}
}
The SyncManager interface allows the browser to delay running the outbox synchronization until the user has reliable connectivity, even if the user closes the browser tab before reconnecting.
React Integration: The useOfflineMutation Hook
Wrapping the outbox pattern inside a custom React hook provides an intuitive developer interface:
import { useState, useCallback } from 'react';
import { queueMutation, QueuedMutation } from './offlineQueue';
import { useOnlineStatus } from './useOnlineStatus';
export function useOfflineMutation<T>() {
const isOnline = useOnlineStatus();
const [isSubmitting, setIsSubmitting] = useState(false);
const mutate = useCallback(
async (endpoint: string, method: 'POST' | 'PUT', payload: T) => {
setIsSubmitting(true);
const mutation: QueuedMutation = {
id: crypto.randomUUID(),
endpoint,
method,
payload,
timestamp: Date.now(),
};
if (!isOnline) {
// Enqueue offline mutation
await queueMutation(mutation);
setIsSubmitting(false);
return { offline: true, message: 'Saved to offline outbox' };
}
try {
// Direct network request when online
const response = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();
setIsSubmitting(false);
return { offline: false, data };
} catch (err) {
// Fallback to queue on unexpected network failure
await queueMutation(mutation);
setIsSubmitting(false);
return { offline: true, message: 'Network failed; queued for sync' };
}
},
[isOnline]
);
return { mutate, isSubmitting };
}
Conflict Resolution Strategies
When multiple clients modify the same record while offline, resolving conflicts becomes essential:
- Last-Write-Wins (LWW): Each record carries a UTC timestamp. The server accepts updates whose timestamp is newer than the existing record. Simple to implement, but risk of silent overwrites.
- Version Vectors / Optimistic Locking: The client sends the
versionnumber it based its edit on. If the server's current version differs, the server rejects the automated merge and returns both versions to the client for user reconciliation. - CRDTs (Conflict-Free Replicated Data Types): For collaborative text or complex documents (like Figma or Notion), libraries like Yjs or Automerge enable mathematically deterministic merges without central server locking.
Conclusion
Offline-first engineering turns network instability from a fatal application failure into an imperceptible background detail. By anchoring client data in IndexedDB, intercepting application bundles with Service Workers, subscribing to connectivity changes via useSyncExternalStore, and persisting mutations through an outbox queue, React developers can build resilient, instantaneous web experiences that perform under any network conditions.