Building web apps in 2026 means planning for unreliable networks, flaky mobile connections, and users who expect everything to keep working instantly. In this guide, you will build an offline-first React app pattern that keeps writes safe when offline, syncs automatically when back online, and gives clear UI feedback. This architecture is practical for dashboards, field apps, and internal tools where data integrity and responsiveness matter.
Why offline-first is now a baseline requirement
React apps used to assume stable internet and immediate API access. That assumption breaks quickly on mobile, in crowded events, or in regions with inconsistent coverage. Offline-first design improves user trust because:
- Actions do not disappear during network drops.
- Users can keep working while disconnected.
- The app recovers automatically without manual refresh.
- You can deliver near-native reliability from the browser.
We will implement three core pieces:
- A local write queue stored in IndexedDB.
- A sync worker that retries with backoff.
- A service worker strategy for fast shell loads and API fallback.
Project setup
Create a React app with Vite and add a tiny IndexedDB helper. You can use Dexie for ergonomic access.
npm create vite@latest offline-react -- --template react-ts
cd offline-react
npm i dexie
npm i idb-keyval
Data model for queued writes
Each queued operation should contain enough context to replay safely, including an idempotency key to prevent duplicate server writes.
import Dexie, { type Table } from 'dexie';
export type QueueItem = {
id?: number;
endpoint: string;
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
payload: unknown;
idempotencyKey: string;
createdAt: number;
attempts: number;
};
class AppDB extends Dexie {
queue!: Table<QueueItem, number>;
constructor() {
super('offline_app_db');
this.version(1).stores({
queue: '++id, createdAt, attempts'
});
}
}
export const db = new AppDB();
Queue writes locally when network is unavailable
Wrap your API writes so they fall back to queueing on network failure. This keeps UX smooth and prevents data loss.
import { db } from './db';
function makeKey() {
return crypto.randomUUID();
}
export async function safeWrite(endpoint: string, payload: unknown) {
const idempotencyKey = makeKey();
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey
},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { queued: false, ok: true };
} catch {
await db.queue.add({
endpoint,
method: 'POST',
payload,
idempotencyKey,
createdAt: Date.now(),
attempts: 0
});
return { queued: true, ok: true };
}
}
React hook for status messaging
Users should know if actions are saved locally and pending sync.
import { useEffect, useState } from 'react';
import { db } from './db';
export function useQueueCount() {
const [count, setCount] = useState(0);
useEffect(() => {
let mounted = true;
async function refresh() {
const c = await db.queue.count();
if (mounted) setCount(c);
}
refresh();
const timer = setInterval(refresh, 2000);
return () => {
mounted = false;
clearInterval(timer);
};
}, []);
return count;
}
Background sync with retry and backoff
When connectivity returns, replay queued writes in order. Add exponential backoff to avoid hammering your API during partial outages.
import { db } from './db';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
export async function flushQueue() {
const items = await db.queue.orderBy('createdAt').toArray();
for (const item of items) {
try {
const res = await fetch(item.endpoint, {
method: item.method,
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': item.idempotencyKey
},
body: JSON.stringify(item.payload)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (item.id) await db.queue.delete(item.id);
} catch {
const attempts = item.attempts + 1;
if (item.id) {
await db.queue.update(item.id, { attempts });
}
const delay = Math.min(1000 * 2 ** attempts, 30000);
await sleep(delay);
}
}
}
Call flushQueue() on app startup and on online events:
window.addEventListener('online', () => {
void flushQueue();
});
void flushQueue();
Service worker caching strategy
Use a cache-first strategy for static assets and network-first for API reads. This gives fast loads while still updating fresh data when possible.
self.addEventListener('fetch', event => {
const req = event.request;
const url = new URL(req.url);
// Static assets: cache-first
if (url.pathname.startsWith('/assets/')) {
event.respondWith(caches.match(req).then(cached => {
return cached || fetch(req).then(res => {
const clone = res.clone();
caches.open('assets-v1').then(c => c.put(req, clone));
return res;
});
}));
return;
}
// API reads: network-first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(req).then(res => {
const clone = res.clone();
caches.open('api-v1').then(c => c.put(req, clone));
return res;
}).catch(() => caches.match(req)));
}
});
Server-side requirements you should not skip
Offline-first is not only frontend work. Your backend must support safe replays:
- Idempotency-Key support for create/update endpoints.
- Conflict handling with clear 409 responses.
- Audit fields like client timestamp and sync timestamp.
- Short-lived retries plus dead-letter workflows for repeated failures.
Testing checklist for production readiness
- Disable network in browser devtools and create 5 to 10 records.
- Reload app offline and verify queued records remain visible.
- Restore network and ensure all writes sync exactly once.
- Simulate HTTP 500 and verify exponential backoff behavior.
- Send duplicate requests with same idempotency key and verify one write.
Final thoughts
React in 2026 is not only about rendering performance, it is about reliability under real-world conditions. With a local queue, deterministic replay, and robust service worker caching, you can deliver apps that feel dependable even on unstable networks. Start with one workflow like form submission, ship it, measure sync success rate, and then expand the pattern across the rest of your app.

Leave a Reply