Offline-First Architecture
The Network Is a Lie
Most web apps treat the network as a given. They show spinners when it's slow and error pages when it's gone. But here's reality: your users are on subway commuter trains, in elevators, in conference halls with overloaded WiFi, on flaky mobile connections that drop every 30 seconds.
Offline-first flips the assumption: design for no network as the default. The network is an enhancement, not a requirement. When the network is there, use it to sync. When it's not, the app keeps working.
This isn't just about "showing a cached page." It's about letting users create, edit, and interact — then syncing changes when connectivity returns. Google Docs, Figma, Notion — they all do this. Your app can too.
Think of offline-first like writing in a notebook on a plane. You keep working without WiFi. When you land, you sync your notes to the cloud. If someone else edited the same note while you were flying, you resolve the conflict. The notebook (local storage) is the source of truth during the flight. The cloud (server) is the source of truth once you're online. The tricky part is merging the two.
The Three Pillars
An offline-first app rests on three technologies:
We covered service workers and caching in the previous topics. Here we focus on the data layer — how to store, queue, and sync user data offline.
Architecture Overview
User Action
|
v
Local State (IndexedDB) <-- reads always come from here
|
v
Mutation Queue (IndexedDB) <-- writes go here
|
v
Sync Engine <-- processes queue when online
|
v
Server API
|
v
Local State (update with server response)
Reads are always local. Writes go to a local queue first, then sync to the server. The UI reads from local state, so it's always fast — no network latency for reads, ever.
Queuing Mutations
When the user creates, edits, or deletes something, don't send it to the server immediately. Store the mutation locally first:
async function addTodo(todo) {
const db = await openDB('app', 1);
const tx = db.transaction(['todos', 'syncQueue'], 'readwrite');
const id = crypto.randomUUID();
const record = { ...todo, id, updatedAt: Date.now(), synced: false };
await tx.objectStore('todos').put(record);
await tx.objectStore('syncQueue').put({
id: crypto.randomUUID(),
type: 'CREATE_TODO',
payload: record,
timestamp: Date.now(),
retries: 0,
});
await tx.done;
if (navigator.onLine) {
syncQueue();
}
return record;
}
The key pattern: one transaction writes both the local data and the sync queue entry. If either fails, both roll back. The UI immediately reflects the new todo because it reads from IndexedDB, not the server.
The Sync Engine
async function syncQueue() {
const db = await openDB('app', 1);
const queue = await db.getAll('syncQueue');
for (const mutation of queue.sort((a, b) => a.timestamp - b.timestamp)) {
try {
const response = await processMutation(mutation);
await applyServerResponse(db, mutation, response);
await db.delete('syncQueue', mutation.id);
} catch (error) {
if (isRetryable(error)) {
mutation.retries++;
await db.put('syncQueue', mutation);
break;
}
await handlePermanentFailure(db, mutation, error);
}
}
}
function isRetryable(error) {
return !navigator.onLine || error.status >= 500 || error.name === 'TypeError';
}
Mutations are processed in order (timestamp-sorted). Network errors pause the queue. Permanent failures (400, 409) are handled individually — maybe the item was deleted on the server, or there's a conflict.
Background Sync Integration
Register a sync event so the browser retries even after the tab is closed:
async function registerSync() {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-mutations');
}
// In the service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-mutations') {
event.waitUntil(syncQueue());
}
});
The browser fires the sync event when it detects connectivity. If syncQueue() fails (throws or the Promise rejects), the browser will retry later with exponential backoff.
Conflict Resolution
The hardest part of offline-first. What happens when two people edit the same item while offline?
Strategy 1: Last Write Wins
The simplest approach. Each record has an updatedAt timestamp. When syncing, the latest timestamp wins.
async function resolveConflict(local, server) {
return local.updatedAt > server.updatedAt ? local : server;
}
Pros: Simple, deterministic. Cons: Data loss — the older edit is silently discarded.
Strategy 2: Server Wins
The server version is always authoritative. Local changes are overwritten on sync.
Good for: collaborative apps where the server maintains consensus, or where local changes are low-value (e.g., read state).
Strategy 3: Merge
Apply both sets of changes. This requires understanding the data structure:
async function mergeDocuments(local, server, base) {
const merged = { ...base };
for (const key of Object.keys(local)) {
if (local[key] !== base[key] && server[key] === base[key]) {
merged[key] = local[key];
} else if (server[key] !== base[key] && local[key] === base[key]) {
merged[key] = server[key];
} else if (local[key] !== base[key] && server[key] !== base[key]) {
if (local[key] === server[key]) {
merged[key] = local[key];
} else {
merged[key] = await promptUserToResolve(key, local[key], server[key]);
}
}
}
return merged;
}
Three-way merge requires keeping a base version (the state before both edits). Compare local and server changes against the base to determine what changed where.
Strategy 4: CRDTs
Conflict-free Replicated Data Types are data structures that can be merged automatically without conflicts. Think of them as "math that guarantees convergence."
Examples: counters (G-Counter), sets (OR-Set), text (Yjs, Automerge). CRDTs are what Google Docs, Figma, and Notion use for real-time collaboration.
This is an advanced topic worthy of its own deep dive, but the principle is: design your data structures so that all possible merge orders produce the same result.
UI Patterns for Offline State
Optimistic UI
Show changes immediately, before they reach the server:
function TodoList() {
const todos = useLiveQuery(() => db.todos.toArray());
async function addTodo(text) {
const todo = {
id: crypto.randomUUID(),
text,
synced: false,
createdAt: Date.now(),
};
await db.todos.put(todo);
syncQueue();
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} data-synced={todo.synced}>
{todo.text}
{!todo.synced && <span aria-label="Pending sync">Syncing</span>}
</li>
))}
</ul>
);
}
The synced: false flag drives the UI — show a subtle indicator that the item hasn't reached the server yet. Once the sync engine confirms, update the flag.
Offline Indicator
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setOnline(true);
const handleOffline = () => setOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return online;
}
navigator.onLine only tells you if the device has a network connection. It does not tell you if the internet is reachable. A connected WiFi with no internet shows onLine: true. For reliable detection, ping a known endpoint (your own server, not google.com) and handle failures gracefully.
Queue Visualization
For power users, showing the pending sync queue builds trust:
function SyncStatus() {
const pending = useLiveQuery(() => db.syncQueue.count());
if (!pending) return null;
return (
<div role="status" aria-live="polite">
{pending} change{pending > 1 ? 's' : ''} waiting to sync
</div>
);
}
Testing Offline Behavior
Chrome DevTools
- Application > Service Workers > Offline checkbox — simulates no network
- Network tab > Throttling > Offline — disables network for the page
- Network tab > Throttling > Slow 3G — tests slow connections
Programmatic Testing
async function testOfflineSync() {
await addTodo({ text: 'Offline todo' });
const queueBefore = await db.syncQueue.count();
console.assert(queueBefore === 1, 'Mutation should be queued');
await syncQueue();
const queueAfter = await db.syncQueue.count();
console.assert(queueAfter === 0, 'Queue should be empty after sync');
}
Service Worker Testing Tips
- Use
workbox-windowto programmatically check service worker state - Test with
Cache-Control: no-storeon API responses to ensure caching is done by the SW, not the HTTP cache - Test the install event by incrementing a version comment in your SW file
- Test background sync by going offline, making changes, then coming online
| What developers do | What they should do |
|---|---|
| Treating offline as an error state with an error page Users don't care why the network is gone. They care that your app works. Showing an error page when you could show cached content is a UX failure. | Design the offline experience as a first-class feature — cache the app shell, store data locally, queue mutations |
| Using localStorage for offline data storage localStorage is synchronous (blocks the main thread), limited to 5MB, and stores only strings. IndexedDB is async, supports structured data, and can store hundreds of megabytes. For offline-first, IndexedDB is the only viable option. | Use IndexedDB for structured offline data — it supports transactions, indexes, and stores megabytes of data |
| Syncing all mutations in parallel Parallel sync can cause impossible states — an edit arriving before its create, a delete before its update. Sequential processing guarantees operations reach the server in the order the user performed them. | Process mutations sequentially in timestamp order to preserve operation ordering |
- 1Reads always come from local storage (IndexedDB) — never block on the network for reads
- 2Writes go to a local mutation queue first, then sync to the server when online
- 3Use Background Sync to replay mutations even after the tab is closed
- 4Show sync status in the UI — users trust apps that tell them what is happening
- 5Choose a conflict resolution strategy (last-write-wins, three-way merge, CRDTs) based on your data's tolerance for lost edits