React 118 ⚛️ Offline-First Applications and Service Workers
An offline-first application assumes the network is unreliable and designs for that assumption from the start. It loads from a local cache, writes to local storage immediately, and synchronizes with the server when connectivity returns. React does not provide this behavior natively; the browser does, through two APIs that work outside the component tree: the Service Worker API for caching assets and intercepting requests, and IndexedDB for storing application data. The React layer’s job is to connect those APIs to the component lifecycle without leaking the browser’s imperative model into the rendering logic.
This chapter covers the architecture of offline-first React applications. It begins with the service worker as a network proxy and the caching strategies that determine which requests succeed offline. It then examines the data layer—IndexedDB for persistence and the sync queue that replays mutations when the connection returns—and the hooks that expose online status and offline data to components. The final sections address the harder problem of conflict resolution, the choice between Workbox and a custom service worker, and the cases where offline-first adds complexity without adding value.
Key point: A service worker makes the app shell load offline. It does not make the application work offline. The data layer—IndexedDB plus a sync queue—is what turns a cached shell into a functional application.
Why offline-first is an architecture, not a feature
The service worker is a proxy, not a cache. A service worker sits between the browser and the network, intercepting every fetch request from a controlled page. It can serve from the Cache API, pass through to the network, or do something more complex like network-first with a cache fallback . This interception is what makes the app shell load without a connection. But the service worker only sees HTTP requests. It does not know that the user created a todo item while offline and needs to sync it later.
The data problem is separate from the asset problem. Workbox and similar toolkits excel at caching strategies: precache the app shell, use cache-first for static assets, use network-first for API responses . What they do not do is queue a POST /api/todos that the user initiated while offline and replay it when the connection returns. That is a data-layer concern, and it requires IndexedDB to persist the queue across page reloads and browser restarts .
The complexity threshold. Offline-first adds a sync queue, conflict resolution, and a persistence layer to every mutation in the application. The react-offline-kit documentation states this directly: the template “adds real complexity; only use it when offline support is a core feature requirement” . An application that is server-driven with occasional connectivity loss does not need this architecture. An application used in the field—inspection tools, rural data collection, mobile-first tools with spotty coverage—does.
The conflict problem is unavoidable. When two clients edit the same record offline and both sync later, the server must decide which version wins. The common strategies are client-wins, server-wins, and last-write-wins based on a timestamp . A custom handler can be supplied for domain-specific rules. The choice matters because it determines whose data is lost when a conflict occurs, and that is a product decision, not a technical one.
a. The service worker and caching strategies
A service worker is a JavaScript file registered from the page. Once installed and activated, it controls the scope it was registered for and intercepts fetch requests within that scope. The registration is a one-time operation that the application performs on load, and the service worker persists across page reloads and browser restarts until it is explicitly unregistered or updated .
The caching strategies are the policies the service worker applies to each request. precacheAndRoute installs the app shell during the service worker’s install event, so that the HTML, JavaScript, and CSS are available on the first offline load . CacheFirst serves from the cache and only goes to the network on a cache miss, which is appropriate for versioned static assets. NetworkFirst tries the network first and falls back to the cache, which is appropriate for API responses where freshness matters but offline access is still desired. StaleWhileRevalidate serves the cached version immediately and updates the cache in the background, which is appropriate for content that changes occasionally .
// public/service-worker.js — Workbox strategies
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
// Precache the app shell injected by the build.
precacheAndRoute(self.__WB_MANIFEST);
// Navigation requests: network first, fall back to cached shell.
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 3 })
);
// API requests: network first, cache fallback for offline.
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({ cacheName: 'api-cache', networkTimeoutSeconds: 5 })
);
// Static assets: cache first, since they are versioned.
registerRoute(
({ request }) => ['style', 'script', 'image'].includes(request.destination),
new CacheFirst({ cacheName: 'assets' })
);
The registration in the React application is a useEffect that checks for service worker support and registers the file. The onupdatefound handler detects when a new service worker is waiting to activate, which is the moment to show the user an “update available” prompt .
// src/serviceWorkerRegistration.ts
export function register() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => {
registration.onupdatefound = () => {
// A new service worker is installing.
// Show the user a refresh prompt.
};
})
.catch((error) => {
console.error('Service worker registration failed:', error);
});
});
}
}
For Next.js applications, @serwist/next wraps this configuration. The withSerwistInit plugin generates the service worker from a TypeScript source file and handles the precache manifest injection. The register option controls whether it registers automatically or whether the application calls window.serwist.register() itself .
b. IndexedDB and the sync queue
The service worker makes the shell load. The data layer makes the application useful. The pattern is consistent across the libraries that implement it: every mutation writes to IndexedDB immediately, pushes an entry onto a sync queue, and attempts to flush the queue to the API if the connection is available . If the flush succeeds, the queue entry is removed. If it fails or the connection is offline, the entry remains and is retried when the connection returns.
react-offline-kit exposes this through a useOfflineData hook. The hook returns the records, the CRUD operations, and a loading flag. The add, update, and remove functions write to IndexedDB and queue the mutation in a single step. The sync engine flushes the queue in the background .
import { useOfflineData } from 'react-offline-kit';
function TodoList() {
const { records, add, update, remove } = useOfflineData('todos');
const handleAdd = async () => {
// Writes to IndexedDB instantly.
// Queues the mutation for sync.
// Flushes if online, queues if not.
await add({ text: 'Buy milk', done: false });
};
return records.map((todo) => <div key={todo.id}>{todo.text}</div>);
}
The sync queue must survive a page reload. If the user closes the browser while offline, the queued mutations must still be there when they return. This is why IndexedDB is the storage layer rather than an in-memory array or localStorage. IndexedDB is asynchronous, transactional, and capable of storing structured data at scale, while localStorage is synchronous, string-only, and limited in size .
The retry mechanism is the other half of the queue. A transient server error should not drop the mutation. Exponential backoff—retrying after 1 second, then 2, then 4, up to a cap—prevents a retry storm while still recovering from brief outages . The @imirfanul/react-offline-sync library implements this with a persisted queue and a prepareHeaders function that refreshes authentication tokens before each sync attempt, since a token that was valid when the user went offline may have expired by the time they reconnect .
c. Online status, conflict resolution, and the library decision
The navigator.onLine property and the online/offline events are the browser’s connectivity signal. They are not perfect—a device can be connected to a Wi-Fi network that has no internet access, and onLine will still report true—but they are the only signal available without a heartbeat request. The useOnlineStatus hook wraps them in a React state value that components can use to disable submit buttons, show an offline banner, or change the UI’s behavior .
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
Conflict resolution is the part of offline-first that cannot be abstracted away. When a record was edited on two clients while offline, the server must choose. react-offline-kit defaults to newer-wins, comparing the updatedAt timestamp on the local and server versions, with client-wins and server-wins as alternatives and a custom function for domain-specific rules . The onConflict callback exposes both versions and the resolved result, which is the place to log or surface the conflict to the user. The choice is not technical; it is a decision about whose work is preserved when two people edit the same record.
The library decision follows from the scope of the offline requirement. react-offline-kit provides a complete template: service worker, IndexedDB wrapper, sync engine, and CRUD hooks, with the source available for customization . @imirfanul/react-offline-sync is a lighter layer focused on queuing mutations with a persisted queue and token refresh . Workbox handles the caching layer and is compatible with any data-layer choice . The wrong choice is to build the sync queue from scratch for a production application; the retry logic, persistence, and conflict handling are where the bugs live.
Complete Example Session
// ============================================
// PART 1: THE ONLINE STATUS HOOK
// ============================================
// The browser's connectivity signal, wrapped in state.
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const online = () => setIsOnline(true);
const offline = () => setIsOnline(false);
window.addEventListener('online', online);
window.addEventListener('offline', offline);
return () => {
window.removeEventListener('online', online);
window.removeEventListener('offline', offline);
};
}, []);
return isOnline;
}
// ============================================
// PART 2: THE SERVICE WORKER REGISTRATION
// ============================================
// Register on load, detect updates.
export function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then((reg) => {
reg.onupdatefound = () => {
const worker = reg.installing;
worker?.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available. Prompt the user.
console.log('Update available');
}
});
};
});
});
}
// ============================================
// PART 3: THE OFFLINE DATA HOOK
// ============================================
// A simplified version of the pattern from react-offline-kit.
// Writes to IndexedDB, queues the mutation, syncs when online.
import { openDB } from 'idb';
const DB_NAME = 'offline-app';
const STORE = 'todos';
const QUEUE = 'sync-queue';
async function getDB() {
return openDB(DB_NAME, 1, {
upgrade(db) {
db.createObjectStore(STORE, { keyPath: 'id' });
db.createObjectStore(QUEUE, { autoIncrement: true });
},
});
}
export function useOfflineTodos() {
const [records, setRecords] = useState<any[]>([]);
const isOnline = useOnlineStatus();
// Load from IndexedDB on mount.
useEffect(() => {
getDB().then(async (db) => {
setRecords(await db.getAll(STORE));
});
}, []);
// Flush the queue when online.
useEffect(() => {
if (!isOnline) return;
const flush = async () => {
const db = await getDB();
const queue = await db.getAll(QUEUE);
for (const entry of queue) {
try {
await fetch(entry.url, entry.options);
await db.delete(QUEUE, entry.id);
} catch {
break; // retry later
}
}
};
flush();
}, [isOnline]);
const add = async (todo: any) => {
const db = await getDB();
const record = { ...todo, id: crypto.randomUUID() };
await db.put(STORE, record);
await db.add(QUEUE, {
url: '/api/todos',
options: { method: 'POST', body: JSON.stringify(record) },
});
setRecords((prev) => [...prev, record]);
};
return { records, add, isOnline };
}
// ============================================
// PART 4: THE OFFLINE BANNER
// ============================================
// Show the user when they are offline.
function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return <div role="status">You are offline. Changes will sync when connected.</div>;
}
// ============================================
// PART 5: THE COMPONENT
// ============================================
// The submit button is disabled when offline.
function TodoApp() {
const { records, add, isOnline } = useOfflineTodos();
const [text, setText] = useState('');
return (
<div>
<OfflineBanner />
<input value={text} onChange={(e) => setText(e.target.value)} />
<button
onClick={() => {
add({ text, done: false });
setText('');
}}
>
Add
</button>
<ul>
{records.map((r) => (
<li key={r.id}>{r.text}</li>
))}
</ul>
</div>
);
}
// ============================================
// PART 6: THE CONFLICT STRATEGY
// ============================================
// The server compares updatedAt timestamps.
// The client can also resolve locally before syncing.
// configureSyncEngine({
// conflictStrategy: 'newer-wins',
// onConflict: ({ local, server, resolved }) => {
// console.log('Conflict:', { local, server, resolved });
// },
// });
// ============================================
// PART 7: THE WORKBOX STRATEGIES
// ============================================
// The service worker caches the shell and API responses.
// precacheAndRoute(self.__WB_MANIFEST);
// registerRoute(
// ({ request }) => request.mode === 'navigate',
// new NetworkFirst({ cacheName: 'pages' })
// );
// registerRoute(
// ({ url }) => url.pathname.startsWith('/api/'),
// new NetworkFirst({ cacheName: 'api-cache' })
// );
// ============================================
// PART 8: THE NEXT.JS VARIANT
// ============================================
// @serwist/next wraps the service worker build.
// import withSerwistInit from '@serwist/next';
// const withSerwist = withSerwistInit({
// swSrc: 'app/sw.ts',
// swDest: 'public/sw.js',
// });
// ============================================
// PART 9: THE PERSISTENCE TEST
// ============================================
// Queue a mutation, reload the page, verify it is still there.
// IndexedDB survives reloads and browser restarts.
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// Service worker caches the shell.
// IndexedDB stores the data and the queue.
// The sync engine flushes when online.
// The React layer renders the state.
export default function App() {
return <TodoApp />;
}
The ten parts build a minimal offline-first todo application: the online hook, the service worker registration, the IndexedDB-backed data hook with a sync queue, the offline banner, and the component. The pattern is consistent with the production libraries: the service worker handles the shell, the data layer handles persistence and queuing, and React renders the result.
Quick Reference
Service Worker Strategies
| Strategy | Behavior | Use For |
|---|---|---|
CacheFirst | Cache, then network | Versioned static assets |
NetworkFirst | Network, then cache | API responses, navigation |
StaleWhileRevalidate | Cache immediately, update background | Content that changes occasionally |
NetworkOnly | Network only | Non-cacheable requests |
CacheOnly | Cache only | Precached shell |
Offline Data Layers
| Layer | Purpose | Survives Reload |
|---|---|---|
| Service Worker Cache | Static assets, app shell | Yes |
localStorage | Small key-value data | Yes |
| IndexedDB | Structured data, sync queue | Yes |
| In-memory | Component state | No |
Sync Queue Patterns
| Pattern | Behavior | Library |
|---|---|---|
| Immediate write + queue | IndexedDB first, sync later | react-offline-kit |
| Persisted queue | Survives browser restart | @imirfanul/react-offline-sync |
| Token refresh | Fresh auth before sync | @imirfanul/react-offline-sync |
| Exponential backoff | Retry with increasing delay | Multiple |
Conflict Strategies
| Strategy | Winner | Use When |
|---|---|---|
newer-wins | Higher updatedAt | General default |
client-wins | Local always wins | Local edits are authoritative |
server-wins | Server always wins | Server is source of truth |
| Custom | Function decides | Domain-specific rules |
Best Practices
✅ Do This:
// Register the service worker on load.
window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js')); // ✅
// Write to IndexedDB before attempting the network.
await db.put(STORE, record); await queueSync(record); // ✅
// Use NetworkFirst for API responses.
new NetworkFirst({ cacheName: 'api-cache' }) // ✅
// Persist the sync queue in IndexedDB.
db.createObjectStore('sync-queue', { autoIncrement: true }) // ✅
// Show an offline banner.
if (!isOnline) return <div>Offline. Changes will sync.</div>; // ✅
// Choose a conflict strategy and document it.
conflictStrategy: 'newer-wins' // ✅
❌ Don’t Do This:
// Don't expect the service worker to queue mutations.
// It only intercepts requests; it does not understand your data. // ❌
// Don't use localStorage for the sync queue.
// It is synchronous, string-only, and size-limited. // ❌
// Don't retry immediately on failure.
// Use exponential backoff to avoid a retry storm. // ❌
// Don't assume navigator.onLine is always accurate.
// A Wi-Fi connection without internet reports true. // ❌
// Don't build the sync queue from scratch for production.
// The retry and persistence logic is where the bugs live. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| App shell loads but data missing | Service worker caches assets, not data | Add IndexedDB and a sync queue |
| Queued mutations lost on reload | Queue in memory or state | Persist to IndexedDB |
| Duplicate syncs | Queue not cleared after success | Delete entry after successful flush |
| Auth fails after long offline | Token expired | Refresh token before sync |
| Stale data on reconnect | No revalidation | NetworkFirst or background revalidate |
| Conflicts silently overwrite | No conflict strategy | Choose and implement one |
Real-World Examples
1. Register Service Worker
navigator.serviceWorker.register('/sw.js');
2. Precache App Shell
precacheAndRoute(self.__WB_MANIFEST);
3. NetworkFirst API
registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api' }));
4. Offline Status Hook
useOnlineStatus() // returns boolean
5. IndexedDB Write
await db.put('todos', record);
6. Queue Mutation
await db.add('sync-queue', { url, options });
7. Flush on Online
useEffect(() => { if (isOnline) flushQueue(); }, [isOnline]);
8. Offline Banner
{!isOnline && <div>Offline</div>}
9. Conflict Handler
onConflict: ({ local, server, resolved }) => console.log(resolved)
10. Next.js Serwist
withSerwistInit({ swSrc: 'app/sw.ts', swDest: 'public/sw.js' })
Visual
The Offline-First Architecture
┌─────────────────────────────────────────────────────────┐
│ REACT COMPONENT │
│ renders records, sends mutations │
│ │ │
│ ▼ │
│ DATA HOOK (useOfflineData) │
│ writes to IndexedDB, queues sync │
│ │ │
│ ┌────┴────┐ │
│ ▼ ▼ │
│ IndexedDB Sync Queue │
│ (records) (pending mutations) │
│ │ │
│ ▼ online? │
│ ┌───────────────────────────────────────────────────┐ │
│ │ SYNC ENGINE │ │
│ │ flush queue to API, handle conflicts, retry │ │
│ └───────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ API SERVER │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ SERVICE WORKER (separate from data layer) │
│ intercepts fetch, serves cached shell and assets │
└─────────────────────────────────────────────────────────┘
The Sync Queue Lifecycle
┌─────────────────────────────────────────────────────────┐
│ OFFLINE │
│ 1. User adds todo │
│ 2. Write to IndexedDB │
│ 3. Push to sync queue │
│ 4. UI updates immediately │
│ │
│ ONLINE │
│ 5. online event fires │
│ 6. Sync engine reads queue │
│ 7. Sends each mutation to API │
│ 8. On success: remove from queue │
│ 9. On failure: retry with backoff │
│ │
│ RELOAD │
│ Queue is in IndexedDB, so it survives. │
│ Sync engine resumes on next online event. │
└─────────────────────────────────────────────────────────┘
Caching Strategy Decision
┌─────────────────────────────────────────────────────────┐
│ What kind of request? │
│ │ │
│ ┌────┴────┬──────────────┐ │
│ ▼ ▼ ▼ │
│ Static Navigation API │
│ asset │ │
│ │ │ ┌────────┴────────┐ │
│ ▼ ▼ ▼ ▼ │
│ CacheFirst NetworkFirst NetworkFirst StaleWhile │
│ (versioned) (fresh HTML) (fresh data, Revalidate │
│ cache fallback) (acceptable │
│ staleness) │
└─────────────────────────────────────────────────────────┘
The Complexity Threshold
┌─────────────────────────────────────────────────────────┐
│ Server-driven app, occasional connectivity loss │
│ → Standard fetch + error handling is sufficient │
│ → Offline-first adds unnecessary complexity │
│ │
│ Field tool, rural data collection, mobile-first │
│ → Offline-first is a core requirement │
│ → Sync queue + IndexedDB + conflict resolution │
│ → Use a library, not a from-scratch implementation │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Service worker | Proxies fetch, serves cached assets |
| Caching strategies | CacheFirst, NetworkFirst, StaleWhileRevalidate |
| Data persistence | IndexedDB (async, structured, large) |
| Sync queue | Persists mutations, replays when online |
| Online signal | navigator.onLine + events |
| Conflict resolution | newer-wins, client-wins, server-wins, custom |
| Library | react-offline-kit, @imirfanul/react-offline-sync |
| Next.js integration | @serwist/next |
| When to use | Offline is a core requirement |
| When not to use | Server-driven app with occasional connectivity |
Key takeaways:
- The service worker and the data layer solve different problems. The service worker caches assets and makes the shell load offline. IndexedDB and the sync queue make the application work offline .
- Workbox handles caching, not mutation queuing. It is excellent at what gets cached and which strategy applies. It does not queue a
POSTthat the user made while offline . - The sync queue must persist. An in-memory queue is lost on reload. IndexedDB survives reloads and browser restarts, which is why it is the storage layer for the queue .
- The conflict strategy is a product decision. When two edits collide, someone’s work is discarded.
newer-winsis the common default, but the choice should be made deliberately . navigator.onLineis a signal, not a guarantee. A device connected to Wi-Fi without internet reportstrue. Use the signal to disable buttons and show banners, not to make critical decisions about data integrity .- Use a library for the sync queue. The retry logic, persistence, and token refresh are where the bugs live.
react-offline-kitand@imirfanul/react-offline-syncprovide the pattern; building it from scratch is a source of subtle failures . - Offline-first adds complexity to every mutation. The
react-offline-kitdocumentation warns that the template adds real complexity and should only be used when offline support is a core requirement . A server-driven app with occasional connectivity loss does not need this architecture.
Remember: An offline-first application is not a React application with a service worker. It is an application that treats the local database as the primary source of truth and the network as a synchronization mechanism. The service worker makes the shell load; the IndexedDB database and the sync queue make the data survive. The React layer renders the local state, and the sync engine reconciles it with the server when connectivity returns. Use a library for the sync layer, choose the conflict strategy deliberately, and only adopt the architecture when the offline requirement justifies the complexity it adds to every mutation in the application.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!