Building Offline-First React Applications
Design React applications that remain usable without a network connection, by detecting connectivity, caching data locally, queuing writes for later sync, and resolving conflicts when connectivity returns.
An offline-first application treats the network as an enhancement, not a requirement: the app loads from a local cache first, lets the user keep working while offline, and reconciles with the server once a connection is available again. This is a fundamentally different design stance from the common 'online-first' default, where every screen assumes fetch will succeed and shows an error state the moment it doesn't. Offline-first matters most for mobile users on unreliable connections, field applications (inspections, deliveries, point-of-sale), and any app where losing a user's work because a network blip interrupted a save would be unacceptable.
The building blocks
Offline-first is not one technique but a stack of cooperating pieces, each solving a different part of the problem: knowing when you're offline, having data to show while offline, keeping the app itself (not just its data) available offline, storing data durably on the client, queuing changes made while offline, and reconciling those changes once connectivity returns.
• Offline detection — navigator.onLine plus the online/offline window events give a first approximation of connectivity; because navigator.onLine can be unreliable (it often only reflects whether the device has a network interface, not whether the internet is actually reachable), production apps supplement it with a periodic lightweight health-check request. • Cached data — every successful fetch response is written into a local store (React Query's cache, a Redux-persisted store, or IndexedDB directly) so that the last known good data can render immediately, even with no network at all. • Service workers — a service worker sits between the app and the network at the browser level, letting you intercept fetch requests and serve cached responses (or the app shell itself) even when the network is completely unavailable, which is what makes an app launchable while offline, not just usable once it's already open. • IndexedDB — the browser's durable, asynchronous, transactional client-side database; unlike localStorage it can store large amounts of structured data (including binary blobs), survives app restarts, and is the standard place to persist both cached server data and a queue of pending writes. • Retry queues — when a write (create/update/delete) happens while offline, instead of failing it, the app records the intended operation in a persistent queue and replays each queued operation against the server once connectivity returns, in order. • Optimistic UI — the interface updates immediately as though a write succeeded, before the server has confirmed it, so the app feels responsive even on a slow or absent connection; the update is rolled back only if the eventual sync reveals it failed. • Conflict resolution — when the same piece of data was changed both locally (while offline) and on the server (by another client) before sync, the app needs an explicit strategy — last-write-wins, field-level merge, or a manual resolution prompt — because silently picking one side can lose the other side's changes. • Synchronization — the overall process of detecting reconnection, replaying the retry queue, pulling fresh server state, and reconciling it with local optimistic changes, ideally triggered automatically the moment the online event fires.
How the pieces fit together
In practice these layer on top of each other in a specific order: detection tells the app whether to attempt network calls at all; the cache and service worker together let the app render and even load while offline; IndexedDB is the durable storage underneath both the cache and the retry queue; optimistic UI is what makes writes feel instant regardless of connectivity; and synchronization is the process that, on reconnection, drains the retry queue and resolves any conflicts that emerged while offline.
A common mistake is implementing only the read side (cached data, service worker) and leaving writes fully online-only — the app then feels offline-capable for browsing but loses every edit a user makes while disconnected. A genuinely offline-first app needs the write path — retry queue, optimistic UI, conflict resolution — just as much as the read path.
Offline detection hook
This hook itself follows the effect-cleanup discipline from the memory-leaks module — the listeners it adds must be removed on unmount. navigator.onLine reflects OS-level network interface state, not actual internet reachability; a device connected to a Wi-Fi network with no internet still reports true.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() { setIsOnline(true); }
function handleOffline() { setIsOnline(false); }
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}Code Examples
A simple, honest signal to the user: rather than letting failed requests surface as confusing errors, the app tells the user up front that it knows it's offline and that their work is not being lost.
Writes made while offline are appended to the pendingWrites object store instead of being sent (and lost) immediately. drainQueue replays them in insertion order once connectivity returns, removing each one from the queue only after the server confirms it — if one fails midway, the loop stops so later, dependent writes aren't applied out of order. IndexedDB is used instead of localStorage specifically because the queue must be transactional (a write either fully commits or doesn't) and because localStorage is synchronous and would block the main thread for larger queues.
The note appears in the list the instant the user submits it, marked pending, regardless of network state. If offline, the write is queued for later sync. If online but the request still fails (server error, timeout), the optimistic entry is rolled back out of state and re-queued rather than left showing a note that was never actually saved.
setNotes((prev) => [...prev, tempNote]); // optimistic update, instant
Rather than draining the queue on a timer, this hook reacts specifically to the offline-to-online transition, comparing the current status against the previous render's status stored in a ref. Once the queue is drained, invalidating the query cache forces a refetch of server state so any changes made by other clients while this user was offline are pulled in too.
Sending the client's last-known updatedAt timestamp lets the server detect that the record changed after this offline edit was made (a 409 Conflict). Comparing timestamps implements a simple last-write-wins policy; a more careful app would instead surface the conflict to the user or merge field-by-field rather than silently discarding one side's edit, but last-write-wins is the simplest correct baseline.
Common mistake: Blindly overwriting the server's record with the queued local write on every sync, without checking for a newer server version first, silently destroys other users' concurrent changes.
function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div role="status" className="offline-banner">
You're offline. Changes will sync when you're back online.
</div>
);
}// idb is a small promise-based wrapper around the native IndexedDB API
import { openDB } from "idb";
const dbPromise = openDB("app-db", 1, {
upgrade(db) {
db.createObjectStore("pendingWrites", { keyPath: "id", autoIncrement: true });
},
});
async function queueWrite(operation: PendingWrite) {
const db = await dbPromise;
await db.add("pendingWrites", operation);
}
async function drainQueue() {
const db = await dbPromise;
const tx = db.transaction("pendingWrites", "readwrite");
const all = await tx.store.getAll();
for (const op of all) {
try {
await syncOperationToServer(op); // e.g. POST /api/notes
await tx.store.delete(op.id);
} catch (err) {
// Leave it queued; it will be retried on the next drain.
break;
}
}
await tx.done;
}function useAddNote() {
const [notes, setNotes] = useState<Note[]>([]);
const isOnline = useOnlineStatus();
async function addNote(text: string) {
const tempNote: Note = { id: `temp-${Date.now()}`, text, pending: true };
setNotes((prev) => [...prev, tempNote]); // optimistic update, instant
if (!isOnline) {
await queueWrite({ type: "create-note", payload: tempNote });
return;
}
try {
const saved = await api.createNote(text);
setNotes((prev) => prev.map((n) => (n.id === tempNote.id ? saved : n)));
} catch {
// Server reachable but the write failed for another reason: roll back.
setNotes((prev) => prev.filter((n) => n.id !== tempNote.id));
await queueWrite({ type: "create-note", payload: tempNote });
}
}
return { notes, addNote };
}function useSyncOnReconnect() {
const isOnline = useOnlineStatus();
const wasOnline = useRef(isOnline);
useEffect(() => {
if (isOnline && !wasOnline.current) {
// Just came back online: replay the queue, then refetch fresh state.
drainQueue().then(() => queryClient.invalidateQueries());
}
wasOnline.current = isOnline;
}, [isOnline]);
}async function syncOperationToServer(op: PendingWrite) {
const response = await api.updateNote(op.payload.id, {
...op.payload,
clientUpdatedAt: op.payload.updatedAt,
});
if (response.status === 409) {
// Server has a newer version than the one this offline edit was based on.
const serverVersion = await response.json();
if (serverVersion.updatedAt > op.payload.updatedAt) {
// Last-write-wins by timestamp: discard the stale local edit.
return { resolution: "server-wins", data: serverVersion };
}
}
return { resolution: "client-wins", data: await response.json() };
}Important Rules
Treat navigator.onLine as a hint, not ground truth — pair it with the online/offline events, and for critical flows, confirm reachability with an actual lightweight network request.
Every write made while offline must be durably queued (IndexedDB, not an in-memory array) so that a tab close or crash before reconnection doesn't lose the user's work.
Optimistic updates must always have a defined rollback path for the case where the eventual sync fails for a reason other than being offline (validation error, permission error, conflict).
Never silently discard one side of a conflicting edit without an explicit resolution policy — last-write-wins by timestamp, field-level merge, or a user-facing prompt are all valid choices, but the choice must be deliberate.
Drain the retry queue in insertion order and stop on the first failure rather than skipping ahead, so that dependent operations (e.g. create-then-update on the same record) are never applied out of sequence.
Where offline-first is non-negotiable
Field-service and logistics apps (delivery drivers, warehouse scanners, home inspectors) routinely operate in basements, rural areas, or moving vehicles with no reliable signal, and a lost inspection report or delivery confirmation is a real business cost, not just a UX inconvenience. These teams typically combine a service worker (via Workbox or a hand-written worker) to keep the app shell loadable offline, IndexedDB for both cached reference data and the write queue, and a background sync mechanism (the Background Sync API where supported, or a manual reconnect-triggered drain as shown above) to guarantee that work done offline eventually reaches the server without the user having to remember to retry anything manually.
if (navigator.onLine) { fetch(url); } // treated as guaranteed reachability if (navigator.onLine) { try { await fetch(url, { signal: AbortSignal.timeout(5000) }); } catch { /* actually unreachable */ } } navigator.onLine only reports whether the device has a network interface up, not whether the internet (or your server specifically) is actually reachable.
const pendingWrites: Write[] = []; // in-memory array used as the offline queue await db.add('pendingWrites', operation); // persisted in IndexedDB
An in-memory queue is lost the moment the tab is closed, refreshed, or crashes — exactly the scenarios an offline-first app is meant to survive.
// sync: always overwrite server data with the queued local edit
// sync: compare server's updatedAt against the edit's base version and resolve the conflict explicitly before writing
Unconditionally applying a queued offline edit can silently destroy changes another client made to the same record while the first client was offline.
Why is IndexedDB preferred over an in-memory array for a retry queue of offline writes? A. IndexedDB is faster than any in-memory data structure B. An in-memory array is lost on tab close, refresh, or crash, while IndexedDB persists the queue durably across all of those C. In-memory arrays cannot store objects, only strings D. IndexedDB automatically retries failed network requests on its own
B. An in-memory array is lost on tab close, refresh, or crash, while IndexedDB persists the queue durably across all of those — The entire point of a retry queue is to guarantee that offline work survives until it can be synced; an in-memory array does not survive the page being closed or crashing, which defeats that guarantee.
What is the main limitation of relying solely on navigator.onLine to decide whether to attempt a network request? A. It is not supported in any modern browser B. It only reflects whether a network interface is up, not whether the internet or a specific server is actually reachable C. It can only be read once per page load D. It requires a service worker to function
B. It only reflects whether a network interface is up, not whether the internet or a specific server is actually reachable — A device can report navigator.onLine === true while connected to a Wi-Fi network that has no actual internet access, so critical flows should confirm reachability with an actual request rather than trusting this flag alone.
In an optimistic UI update, when should the change be rolled back? A. Never — optimistic updates are always kept regardless of outcome B. Only when the device is offline C. Whenever the eventual sync reveals the write failed for a reason other than simply being offline (e.g. validation or conflict error) D. Every time the component re-renders
C. Whenever the eventual sync reveals the write failed for a reason other than simply being offline (e.g. validation or conflict error) — Being offline is handled by queuing the write for later; a rollback is specifically needed when the write reaches the server but is rejected for another reason, since in that case the optimistic state no longer reflects what will actually be persisted.
Extend the useAddNote example so that if the app is closed while offline (queued write still pending) and reopened later while still offline, the pending note still appears in the notes list marked as pending, sourced from IndexedDB rather than lost React state.
useEffect(() => { (async () => { const db = await dbPromise; const cached = await db.getAll('notesCache'); const pending = await db.getAll('pendingWrites'); const pendingNotes = pending .filter((op) => op.type === 'create-note') .map((op) => ({ ...op.payload, pending: true })); setNotes([...cached, ...pendingNotes]); })(); }, []);
On mount, read both the confirmed notes cache and the pendingWrites store from IndexedDB, and merge queued creates into the rendered list before the queue has been drained.