π Offline-First Development Strategies
Offline-first is a design philosophy, not just a feature: you treat the network as an enhancement, not a requirement. The app reads and writes local data first and syncs opportunistically. This lesson covers the storage, sync, and conflict-resolution patterns that make that dependable β and how to test them.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Contrast the traditional and offline-first mental models
- Choose the right client storage (IndexedDB, Cache API, Web Storage) for each kind of data
- Implement optimistic UI and a durable queue-based sync with exponential backoff
- Apply the common conflict-resolution strategies and their trade-offs
- Detect and communicate network status and test offline behavior
Estimated Time: 45β55 minutes β’ Difficulty: IntermediateβAdvanced
Hands-on: Build an offline-first todo flow with optimistic UI and a sync queue that drains on reconnect.
In This Lesson
The Offline-First Mindset
Most apps are built network-first: they assume a connection and treat its absence as an error state. Offline-first inverts that assumption. The local device is the source of truth for the current session, the UI works whether or not there's a signal, and the network is used to sync in the background. Connectivity becomes a spectrum β great, poor, intermittent, none β rather than a binary.
π‘ The elevator analogy: A network-first app is an elevator that traps you when the power cuts. An offline-first app switches to backup power β it still moves you safely to the next floor and keeps limited service running until main power returns. The failure is planned for, not fatal.
π Core principles
Availability: the app is usable regardless of network state.
Local-first performance: read from local storage before the network.
Resilience: transitions between online and offline are seamless.
Eventual consistency: local and remote converge when connectivity returns.
Choosing Client Storage
Offline-first lives or dies on client storage. Three APIs cover almost every need; match the tool to the data.
| API | Capacity | Data types | Best for |
|---|---|---|---|
| IndexedDB | Large (quota-based) | Structured objects, blobs | App data, queues, queryable records |
| Cache API | Large (quota-based) | Request/Response pairs | Assets and HTTP responses (from the SW) |
| localStorage / sessionStorage | ~5 MB | Strings only (synchronous) | Small flags, preferences, tokens |
β οΈ Avoid localStorage for real data
Its API is synchronous, so it blocks the main thread, and it's capped near 5 MB and strings-only. It's fine for a theme flag; it is the wrong home for a document store. WebSQL is fully deprecated β never start with it.
IndexedDB β the workhorse
IndexedDB is a transactional, indexed object database in the browser. Its native API is famously verbose (event-based). A realistic pattern wraps it in promises; in production most teams reach for a thin library like idb. Here's the raw shape so you understand what those libraries do:
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('app-db', 1);
req.onupgradeneeded = () => {
const db = req.result;
const store = db.createObjectStore('todos', { keyPath: 'id' });
store.createIndex('pending', 'pending', { unique: false });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function putTodo(todo) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction('todos', 'readwrite');
tx.objectStore('todos').put(todo);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
β The same code with the idb library
import { openDB } from 'idb';
const db = await openDB('app-db', 1, {
upgrade(db) {
const store = db.createObjectStore('todos', { keyPath: 'id' });
store.createIndex('pending', 'pending');
}
});
await db.put('todos', todo); // write
const all = await db.getAll('todos'); // read
Same database, a fraction of the ceremony. Learn the raw API once, then use a wrapper.
Optimistic UI Updates
Waiting for a server round-trip before showing a change feels sluggish β and impossible offline. With optimistic UI you update the interface immediately, assuming success, then reconcile when (or if) the server responds. If it fails, you roll back.
async function addTodo(text) {
const todo = {
id: `tmp-${crypto.randomUUID()}`, // temporary local id
text,
completed: false,
pending: true, // not yet confirmed by the server
updatedAt: Date.now()
};
await putTodo(todo); // 1. persist locally
renderTodo(todo); // 2. show it right away
try {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
if (!res.ok) throw new Error(res.statusText);
const saved = await res.json();
await putTodo({ ...todo, id: saved.id, pending: false });
reconcileId(todo.id, saved.id); // 3a. swap temp id for real id
} catch {
await enqueue({ type: 'create', payload: todo }); // 3b. queue for later
}
}
π‘ Mark, don't hide
Keep a pending flag on unsynced records and reflect it subtly in the UI (a faint clock icon, reduced opacity). Users trust an app that's honest about what has and hasn't been saved yet.
Queue-Based Synchronization
When a write can't reach the server, don't drop it β append it to a durable sync queue in IndexedDB and drain the queue when connectivity returns (via the online event or a service-worker sync event). Failed items retry with exponential backoff and eventually land in a dead-letter state you can surface to the user.
async function processQueue() {
const db = await openDb();
const items = await getAll(db, 'syncQueue'); // ordered by timestamp
for (const item of items) {
try {
const res = await fetch(item.url, {
method: item.method,
headers: { 'Content-Type': 'application/json' },
body: item.body
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await remove(db, 'syncQueue', item.id); // success β drop it
} catch (err) {
item.retries += 1;
if (item.retries >= 5) {
item.status = 'failed'; // dead-letter
} else {
item.nextRetry = Date.now() + 2 ** item.retries * 1000; // backoff
}
await put(db, 'syncQueue', item);
break; // stop on first failure; try again on next trigger
}
}
}
// Drain whenever the browser reports we're back online.
window.addEventListener('online', () => {
processQueue().catch(console.error);
});
β οΈ Make operations idempotent
A queued request may be sent more than once (a retry after a response that never arrived). Give each operation a stable client-generated id and have the server treat a repeat as a no-op, so a double-send can't create duplicates.
Conflict Resolution
When the same record is edited locally and remotely, their versions diverge. You need a policy. There is no universally right answer β it depends on the data and how much you trust each side.
| Strategy | Rule | Good for |
|---|---|---|
| Server wins | Remote is authoritative; discard local | Read-mostly data, prices, inventory |
| Client wins | Local overwrites remote | Personal drafts, single-device data |
| Last write wins | Newest updatedAt keeps | Simple fields, low conflict rate |
| Merge | Combine both (e.g. CRDTs / OT) | Collaborative editing (Notion, Figma) |
| Manual | Ask the user to pick | High-value, ambiguous conflicts |
// Last-write-wins reconciliation on sync.
async function reconcile(localItem) {
const res = await fetch(`/api/items/${localItem.id}`);
const serverItem = await res.json();
if (serverItem.updatedAt > localItem.updatedAt) {
await putTodo(serverItem); // server is newer β adopt it
return serverItem;
}
return uploadItem(localItem); // local is newer β push it up
}
π‘ True collaboration needs merges, not overwrites
Last-write-wins silently loses one person's edits. For multi-user editing, apps like Notion and Figma use operational transforms (OT) or conflict-free replicated data types (CRDTs) so concurrent changes merge without data loss. Libraries such as Yjs and Automerge implement CRDTs for you.
Network Status & UX
Communicate connectivity clearly but calmly. Use the online/offline events for the coarse state and the Network Information API for quality hints.
function updateStatus() {
document.body.classList.toggle('is-offline', !navigator.onLine);
}
window.addEventListener('online', () => { updateStatus(); processQueue(); });
window.addEventListener('offline', updateStatus);
updateStatus();
// Optional: adapt to connection quality where supported.
const conn = navigator.connection;
if (conn) {
conn.addEventListener('change', () => {
if (conn.saveData || conn.effectiveType === '2g') {
loadLowResAssets(); // be frugal on slow / save-data connections
}
});
}
β οΈ navigator.onLine lies
navigator.onLine === true only means the device has a network interface, not that your server is reachable (captive-portal WiFi, DNS failure, dead backend). Treat it as a hint. The reliable signal that a request will succeed is that the request actually succeeded β build your sync around fetch outcomes, not just the flag.
β UX guidelines
- Show an unobtrusive banner or badge for offline state β never a blocking modal.
- Indicate per-item sync status (pending / synced / failed).
- Offer a manual "Sync now" for users who want control.
- Communicate data age when showing cached content that might be stale.
Hands-on Exercise
ποΈ Offline-first todo flow
Objective: Add a todo, show it instantly, and have it sync when the network returns.
Instructions:
- Create an IndexedDB store
todos(keyPathid) and asyncQueuestore. - On "Add", write the todo with
pending: true, render it immediately (optimistic UI), and attempt a POST. - If the POST fails (or you're offline), append a
createoperation to the sync queue. - On the
onlineevent, drain the queue with exponential backoff; on success, flippendingto false and swap the temp id. - Toggle DevTools β Network β Offline, add two todos, go back online, and confirm both sync.
π‘ Hint
Use crypto.randomUUID() for temporary ids so retries stay idempotent. Reflect pending visually with a CSS class. If nothing syncs on reconnect, confirm your online listener is attached before going offline, and that processQueue() reads items in timestamp order.
β Core of the solution
async function addTodo(text) {
const todo = { id: `tmp-${crypto.randomUUID()}`, text,
pending: true, updatedAt: Date.now() };
await db.put('todos', todo);
renderTodo(todo);
if (!navigator.onLine) {
return db.put('syncQueue', { id: todo.id, op: 'create', body: todo });
}
try {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
const saved = await res.json();
await db.delete('todos', todo.id);
await db.put('todos', { ...todo, id: saved.id, pending: false });
reconcileId(todo.id, saved.id);
} catch {
await db.put('syncQueue', { id: todo.id, op: 'create', body: todo });
}
}
window.addEventListener('online', () => processQueue());
π― Quick Quiz
Question 1: Which storage API is the best fit for a queryable store of structured application records?
Question 2: What does optimistic UI do?
Question 3: Why is navigator.onLine === true not a guarantee your request will succeed?
Testing & Pitfalls
How to test offline behavior
- DevTools β Network β Offline / throttling to simulate no or poor connectivity.
- Real devices in airplane mode β emulators hide timing quirks.
- Drop the connection mid-operation to exercise your queue and rollback paths.
- Automated tests: mock
fetchto reject and assert data was queued and later drained.
test('queues writes when offline', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('offline'));
const enqueue = vi.spyOn(queue, 'add');
await addTodo('Buy milk');
expect(enqueue).toHaveBeenCalledOnce();
expect(fetch).toHaveBeenCalledTimes(1); // attempted, then queued on failure
});
β οΈ Common pitfalls
- Storage quotas: writes can fail when the origin is over quota β check
navigator.storage.estimate()and handleQuotaExceededError. - Stale caches: timestamp cached data and show its age; use stale-while-revalidate for content that drifts.
- Lost writes: non-idempotent retries create duplicates β use stable client ids.
- Silent sync failures: surface dead-lettered items instead of dropping them.
Summary & Quiz
π Key Takeaways
- Offline-first treats the network as an enhancement: read/write locally, sync opportunistically.
- Use IndexedDB for structured data, the Cache API for responses, and Web Storage only for tiny flags.
- Optimistic UI keeps the app responsive; a durable sync queue with backoff keeps writes safe.
- Pick a conflict-resolution policy per data type; true collaboration needs merges (CRDTs/OT), not overwrites.
navigator.onLineis a hint, not a guarantee β build sync around real fetch outcomes and idempotent operations.
π Further Reading
- web.dev β The Offline Cookbook
- MDN β IndexedDB API
- idb β a tiny IndexedDB promise wrapper
- crdt.tech β Conflict-free Replicated Data Types
π What's Next?
You've completed the PWA arc β architecture, service workers, and offline-first design. Next you'll bring the whole module together in the weekend project, combining a framework, state management, and these offline capabilities into one app.
π Nice work!
You can now build apps that shrug off a dead network. On to the weekend project.