Offline-First Design
Also Known As
TL;DR
Explanation
Offline-first inverts the traditional assumption that network availability is the default state. Instead of making network requests and showing errors when they fail, offline-first applications read from local storage first (IndexedDB, Cache API, localStorage) and synchronise with the server when connectivity is available. This requires conflict resolution strategies for data modified both locally and on the server — last-write-wins is simplest but loses data; operational transforms and CRDTs (Conflict-free Replicated Data Types) handle merging correctly. For PHP backends, offline-first typically means implementing a sync API that accepts batches of locally-generated operations with timestamps rather than a traditional REST API that expects immediate round-trips.
Common Misconception
Why It Matters
Common Mistakes
- Treating offline-first as a feature to add after launch — the sync architecture must be designed upfront; retrofitting it to a traditional request-response API is very difficult.
- Using localStorage for structured data — IndexedDB supports proper querying and larger storage limits; localStorage is synchronous and blocks the main thread.
- Not implementing conflict resolution — silently overwriting server data with local changes when both were modified loses work.
- Assuming Background Sync API is universally supported — it is Chrome-only; implement optimistic UI updates with retry queues as a universal fallback.
Code Examples
// Network-required — breaks completely offline
async function saveNote(note) {
const res = await fetch('/api/notes', {
method: 'POST',
body: JSON.stringify(note)
});
if (!res.ok) throw new Error('Failed'); // user loses work
}
// Offline-first — save locally first, sync later
async function saveNote(note) {
note.id = crypto.randomUUID();
note.syncedAt = null;
// Save to IndexedDB immediately
await db.notes.put(note);
updateUI(note); // optimistic update
// Try to sync — queue if offline
try {
await fetch('/api/notes', {method:'POST', body: JSON.stringify(note)});
await db.notes.update(note.id, {syncedAt: Date.now()});
} catch {
// Will retry via Background Sync when online
await navigator.serviceWorker.ready
.then(reg => reg.sync.register('sync-notes'));
}
}