Connectivity is not a binary. For millions of users across Ghana, Nigeria, Kenya, and beyond, the internet is something that comes and goes — throttled on a crowded tower, interrupted mid-session, or simply expensive enough to ration. If your PWA only works well online, you have not built a product; you have built a liability.
Offline-first is not a fallback mode. It is an architectural decision made upfront, where local state is the source of truth and the server is a sync target. Here is how to build it properly.
The Mental Model: Local First, Sync Second
Most web apps treat the server as the source of truth and the UI as a reflection of it. Offline-first flips this: your app reads from and writes to a local database immediately. The network layer becomes a background process that reconciles local state with the server when connectivity allows.
This means your users experience zero-latency reads and writes regardless of signal quality. The sync happens silently, and conflicts are resolved by rules you define — not by errors you throw.
The two pillars of this architecture are IndexedDB (for structured local persistence) and Service Workers (for network interception and background sync).
Setting Up IndexedDB the Right Way
The raw IndexedDB API is callback-heavy and verbose. Use the idb wrapper library from Jake Archibald — it is Promise-based, lightweight, and production-ready.
// db.js — initialize your local database
import { openDB } from 'idb';
export const dbPromise = openDB('app-store', 1, {
upgrade(db) {
// Orders waiting to sync
const syncQueue = db.createObjectStore('syncQueue', {
keyPath: 'id',
autoIncrement: true,
});
syncQueue.createIndex('status', 'status');
// Cached server data
db.createObjectStore('orders', { keyPath: 'orderId' });
},
});
// Write a new order locally and queue it for sync
export async function createOrderLocally(order) {
const db = await dbPromise;
const tx = db.transaction(['orders', 'syncQueue'], 'readwrite');
await tx.objectStore('orders').put({ ...order, _synced: false });
await tx.objectStore('syncQueue').add({
type: 'CREATE_ORDER',
payload: order,
status: 'pending',
createdAt: Date.now(),
});
await tx.done;
}
A few design decisions worth noting here:
- Dual-store approach: One store holds your actual data; another holds a sync queue of pending mutations. This keeps concerns separate and makes retry logic clean.
_syncedflag: Tag local records so your UI can show optimistic states (e.g., a "pending" badge) without blocking the user.- Transactions span stores: IndexedDB transactions are atomic. Writing to both stores in one transaction means you never have a queued action without a corresponding local record.
Intercepting Requests With a Service Worker
Your service worker sits between the browser and the network. For an offline-first app, it should serve cached assets immediately and queue failed API mutations for later.
Register your service worker in your app entry point, then implement a stale-while-revalidate strategy for GET requests and a queue-and-sync strategy for POST/PUT/DELETE.
For background sync, use the Background Sync API. When a mutation fails due to no connectivity, register a sync tag. The browser will fire the sync event when connectivity is restored — even if the user has closed your tab.
// In your service worker (sw.js)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-orders') {
event.waitUntil(syncPendingOrders());
}
});
async function syncPendingOrders() {
const db = await openDB('app-store', 1);
const pending = await db.getAllFromIndex('syncQueue', 'status', 'pending');
for (const item of pending) {
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.payload),
});
if (response.ok) {
const tx = db.transaction('syncQueue', 'readwrite');
await tx.store.delete(item.id);
await tx.done;
}
} catch {
// Leave as pending; browser will retry
}
}
}
Handling Conflicts Without Losing Data
Conflict resolution is where most offline-first implementations cut corners. The simplest approach is last-write-wins with a timestamp, but this silently drops data in multi-device scenarios.
A more robust pattern for SaaS applications:
- Attach a
clientUpdatedAttimestamp to every mutation in the sync queue. - On the server, compare
clientUpdatedAtagainst the record'sserverUpdatedAt. - If the server version is newer, return the conflict to the client with a
409 Conflictstatus and let the user or business logic decide. - For non-critical fields (e.g., view counts, analytics), last-write-wins is acceptable. For financial or inventory data, always surface the conflict explicitly.
Optimising for Low-Bandwidth Conditions
Building for markets where 2G is still common requires a few additional disciplines:
- Compress your sync payloads. Only send delta changes, not full records. If an order was updated, send only the changed fields and the record ID.
- Batch sync requests. Instead of firing one fetch per queued item, batch pending mutations into a single API call. This reduces round-trips dramatically on high-latency connections.
- Cache aggressively. Use a
Cache-Firststrategy for static assets (shell, fonts, icons) andNetwork-Firstwith a cache fallback for API responses. Workbox makes these strategies declarative and easy to maintain. - Monitor sync queue depth. If a user has more than, say, 50 unsynced records, surface a warning in the UI. Silent queue buildup can lead to a jarring sync explosion when connectivity returns.
- Respect data costs. Offer users a "sync on Wi-Fi only" setting, especially for apps that deal with image uploads or large payloads.
Testing Your Offline Behavior
Chrome DevTools makes this straightforward. Under the Network panel, toggle the "Offline" preset and interact with your app. Then restore connectivity and watch the sync queue drain.
Write integration tests that simulate the full cycle: write locally while offline, come back online, and assert that the server received the correct payload. Tools like Playwright support network condition emulation, making this automatable in CI.
Why This Matters for Your Project
If you are building a SaaS product, field data collection tool, mobile commerce app, or any customer-facing platform targeting users in emerging markets, offline-first is not a nice-to-have. It is the difference between an app people trust and one they abandon at the first bad signal. The architecture described here — IndexedDB as local state, service workers as the sync layer, and disciplined conflict resolution — scales from a simple CRUD app to a complex multi-user system. Building it right from the start is far cheaper than retrofitting it after launch.





