Learn the concept
Data Layer Architecture
An offline-first web application uses Service Workers for asset caching and IndexedDB for local data storage, enabling full functionality without network connectivity. Synchronization is handled through a queue-based system with conflict resolution strategies like last-write-wins or CRDTs, while optimistic UI ensures instant feedback for user actions.
Offline-first is an architectural approach where the application is designed to work without a network connection as the default state, and network connectivity is treated as an enhancement. This is fundamentally different from "offline-capable" (where online is the default and offline is a fallback). The distinction matters because it changes how you model data flow, conflict resolution, and user expectations.
A Service Worker intercepts all network requests and serves cached responses when the network is unavailable. This ensures the application shell (HTML, CSS, JavaScript) loads instantly regardless of connectivity.
Cache strategies:
IndexedDB is the only browser storage API suitable for offline-first applications because it supports structured data, indexes, transactions, and large storage quotas (typically 50-80% of available disk space, compared to localStorage's 5-10MB limit).
Design your IndexedDB schema with sync in mind:
synced, pending, conflict.All write operations (create, update, delete) are first applied to IndexedDB and then added to an outbox queue. When the network becomes available, the queue is drained in order:
{ id, operation, data, timestamp, retryCount }.online event fires, sync resumes.syncStatus becomes synced.Conflicts occur when two devices modify the same record while both are offline. There is no perfect solution — each strategy has trade-offs:
The change with the most recent timestamp overwrites the other. Simple to implement but can silently lose data. Suitable for settings, preferences, and non-critical data.
The server always wins conflicts. The client's version is discarded or saved as a "local draft." Suitable for financial data, inventory counts, and shared resources where consistency matters more than user autonomy.
Present both versions to the user and let them choose or merge manually. Similar to Git merge conflicts. Suitable for documents, notes, and content where user intent matters.
CRDTs are data structures that can be merged automatically without conflicts. Examples:
CRDTs are mathematically guaranteed to converge, making them ideal for collaborative applications. Libraries like Yjs and Automerge implement them.
The user should never wait for the network. When they perform an action:
This creates the perception of a fast, responsive application even on slow or unreliable networks.
navigator.onLine is unreliable — it only detects whether the device has a network interface, not whether it can reach your server. Supplement it with:
fetch a small endpoint (e.g., /api/ping) with a short timeout.Browsers limit storage. Use the Storage Manager API to check quota:
const estimate = await navigator.storage.estimate();
const usedMB = estimate.usage / 1024 / 1024;
const quotaMB = estimate.quota / 1024 / 1024;Request persistent storage to prevent the browser from evicting your data under storage pressure: await navigator.storage.persist(). Implement a cleanup strategy for old synced data when approaching quota limits.
The application should work at three levels:
The Background Sync API lets you defer actions until the user has stable connectivity. Register a sync event in the Service Worker, and the browser will fire it when it detects a reliable connection — even if the user has closed the tab.
// service-worker.js
const CACHE_NAME = 'app-shell-v2';
const ASSETS_TO_CACHE = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/manifest.json',
'/icons/icon-192.png',
];
// Install: pre-cache app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS_TO_CACHE))
);
self.skipWaiting(); // Activate immediately
});
// Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
);
self.clients.claim(); // Take control of all pages
});
// Fetch: apply cache strategies based on request type
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API requests: network-first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: cache-first
event.respondWith(cacheFirst(request));
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
return new Response('Offline', { status: 503 });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open('api-cache');
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
return cached || new Response(JSON.stringify({ error: 'Offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
}
// Background Sync: process queued writes when online
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-outbox') {
event.waitUntil(processOutbox());
}
});
async function processOutbox() {
const db = await openDB();
const tx = db.transaction('outbox', 'readonly');
const entries = await tx.objectStore('outbox').getAll();
for (const entry of entries) {
try {
await fetch(entry.url, {
method: entry.method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry.data),
});
// Remove from outbox on success
const deleteTx = db.transaction('outbox', 'readwrite');
await deleteTx.objectStore('outbox').delete(entry.id);
} catch {
// Will retry on next sync event
break;
}
}
}Users write and edit notes anywhere — on a plane, subway, or in areas with poor connectivity — and all changes sync seamlessly when they reconnect
Inspectors, surveyors, or healthcare workers collect data in remote locations without connectivity, with everything syncing when they return to coverage
Multiple users edit the same document simultaneously or asynchronously, with CRDT-based conflict resolution ensuring all changes merge correctly
Build a todo application that works completely offline using IndexedDB for storage, a sync queue for deferred writes, and last-write-wins conflict resolution when syncing with a server
Create a developer tool that visualizes Service Worker cache contents, shows cache hit/miss rates, and displays storage quota usage in real time
Uses an offline-first architecture with IndexedDB as the primary local data store, enabling instant search, navigation, and offline access with background sync via WebSocket
Caches page content in IndexedDB for instant loading and supports offline editing with block-level sync when reconnected