Offline-first means designing your application to work without a network connection as the default, and enhancing the experience when connectivity is available. Instead of treating offline as an error state, you treat it as a normal mode of operation.
Why Offline-First?
Network connections are unreliable. Users ride elevators, take flights, pass through tunnels, and visit areas with poor coverage. An offline-first app keeps working through all of these situations.
| Approach | Offline Behavior | User Experience |
|---|---|---|
| Online-only | Blank page or error | Frustrating |
| Offline-fallback | Shows cached version of last page | Acceptable |
| Offline-first | Full functionality with sync | Seamless |
IndexedDB for Local Storage
While the Cache API stores request/response pairs, IndexedDB is a full client-side database for structured data.
Setting Up IndexedDB
function openDB(name, version) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains("tasks")) {
const store = db.createObjectStore("tasks", { keyPath: "id" });
store.createIndex("status", "status");
store.createIndex("createdAt", "createdAt");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}CRUD with IndexedDB
class TaskStore {
constructor() {
this.dbPromise = openDB("myapp", 1);
}
async getAll() {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction("tasks", "readonly");
const store = tx.objectStore("tasks");
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async add(task) {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction("tasks", "readwrite");
const store = tx.objectStore("tasks");
const request = store.put(task);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async delete(id) {
const db = await this.dbPromise;
return new Promise((resolve, reject) => {
const tx = db.transaction("tasks", "readwrite");
const store = tx.objectStore("tasks");
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}Using idb for Simpler IndexedDB
The raw IndexedDB API is verbose. The idb library wraps it with promises:
npm install idbimport { openDB } from "idb";
const db = await openDB("myapp", 1, {
upgrade(db) {
db.createObjectStore("tasks", { keyPath: "id" });
},
});
// Add
await db.put("tasks", { id: "1", title: "Learn PWAs", status: "todo" });
// Get one
const task = await db.get("tasks", "1");
// Get all
const tasks = await db.getAll("tasks");
// Delete
await db.delete("tasks", "1");Sync Queue Pattern
When offline, queue changes locally. When back online, sync with the server.
class SyncQueue {
constructor() {
this.dbPromise = openDB("sync-queue", 1, {
upgrade(db) {
db.createObjectStore("queue", {
keyPath: "id",
autoIncrement: true,
});
},
});
}
async add(action) {
const db = await this.dbPromise;
await db.put("queue", {
...action,
timestamp: Date.now(),
retries: 0,
});
}
async getAll() {
const db = await this.dbPromise;
return db.getAll("queue");
}
async remove(id) {
const db = await this.dbPromise;
await db.delete("queue", id);
}
async process() {
const actions = await this.getAll();
for (const action of actions) {
try {
await fetch(action.url, {
method: action.method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(action.data),
});
await this.remove(action.id);
console.log(`Synced: ${action.type}`);
} catch (error) {
console.log(`Failed to sync: ${action.type}, will retry`);
}
}
}
}
const syncQueue = new SyncQueue();
// Queue an action when offline
async function createTask(task) {
// Save locally immediately
await taskStore.add(task);
if (navigator.onLine) {
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(task),
});
} else {
await syncQueue.add({
type: "CREATE_TASK",
url: "/api/tasks",
method: "POST",
data: task,
});
}
}
// Process queue when back online
window.addEventListener("online", () => {
syncQueue.process();
});Background Sync API
The Background Sync API lets the service worker retry failed requests even after the user leaves the page:
// main.js — register a sync event
async function saveData(data) {
// Store data in IndexedDB
await db.put("outbox", data);
// Request a background sync
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-data");
}// sw.js — handle the sync event
self.addEventListener("sync", (event) => {
if (event.tag === "sync-data") {
event.waitUntil(syncOutbox());
}
});
async function syncOutbox() {
const db = await openDB("myapp", 1);
const items = await db.getAll("outbox");
for (const item of items) {
try {
await fetch("/api/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(item),
});
await db.delete("outbox", item.id);
} catch (error) {
// Sync will retry automatically
throw error;
}
}
}Online/Offline Detection
function setupConnectivityListeners() {
function updateUI() {
const indicator = document.getElementById("connectivity");
if (navigator.onLine) {
indicator.textContent = "Online";
indicator.className = "status-online";
} else {
indicator.textContent = "Offline — changes will sync when reconnected";
indicator.className = "status-offline";
}
}
window.addEventListener("online", () => {
updateUI();
syncQueue.process();
});
window.addEventListener("offline", updateUI);
updateUI();
}Note that navigator.onLine only tells you if the device has a network interface. It does not guarantee the server is reachable. Always handle network errors gracefully regardless of this property.
Conflict Resolution
When both the client and server modify the same data while offline, you need a conflict resolution strategy:
| Strategy | Description | Best For |
|---|---|---|
| Last write wins | Most recent timestamp overwrites | Simple apps |
| Server wins | Server version always takes priority | Authoritative data |
| Client wins | Client version always takes priority | User-first apps |
| Manual merge | Show both versions, let user choose | Critical data |
async function resolveConflict(local, remote) {
// Last write wins
if (local.updatedAt > remote.updatedAt) {
await pushToServer(local);
} else {
await saveLocally(remote);
}
}Practical Exercise
Build an offline-capable note-taking app:
import { openDB } from "idb";
const db = await openDB("notes-app", 1, {
upgrade(db) {
const store = db.createObjectStore("notes", { keyPath: "id" });
store.createIndex("updatedAt", "updatedAt");
db.createObjectStore("sync-queue", {
keyPath: "id",
autoIncrement: true,
});
},
});
async function saveNote(note) {
const data = {
...note,
id: note.id || crypto.randomUUID(),
updatedAt: Date.now(),
synced: false,
};
await db.put("notes", data);
if (navigator.onLine) {
try {
await fetch("/api/notes", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
await db.put("notes", { ...data, synced: true });
} catch {
await db.put("sync-queue", { type: "SAVE_NOTE", data });
}
} else {
await db.put("sync-queue", { type: "SAVE_NOTE", data });
}
return data;
}
async function loadNotes() {
return db.getAllFromIndex("notes", "updatedAt");
}Key Takeaways
- Offline-first treats the network as an enhancement, not a requirement.
- IndexedDB stores structured data locally; the
idblibrary makes it easier to use. - The sync queue pattern buffers changes offline and replays them when connectivity returns.
- The Background Sync API lets service workers retry failed syncs even after the page is closed.
- Choose a conflict resolution strategy (last write wins, server wins, manual merge) based on your data's criticality.