How to Build an Offline-First PWA with IndexedDB and Sync
A user in Kumasi opens your app on a trotro with two bars of signal. She fills out a form, hits submit, and the spinner just... spins. Then the connection drops. Her work is gone. She never comes back.
That is not a fringe case — it is a core user experience failure that a large portion of African mobile users encounter daily. Building a progressive web app that works only when connectivity is reliable is not building for your actual users. This guide shows you how to architect a genuinely offline-first PWA: one that writes to IndexedDB immediately, queues operations for sync, and reconciles intelligently once connectivity returns.
Why IndexedDB Is the Right Tool for This
The browser's storage landscape includes cookies, localStorage, sessionStorage, and Cache API — but none of them are suited for structured, queryable, asynchronous write operations. IndexedDB is a full transactional database inside the browser. It supports indexes, cursors, and bulk operations, and it does not block the main thread.
For offline-first architecture, IndexedDB serves two purposes:
- Data store — persisting the application's working data locally
- Outbox queue — holding write operations (POST, PUT, DELETE) that have not yet been sent to the server
Keep these as two separate object stores. Conflating them is one of the most common architectural mistakes in offline PWA work.
Structuring Your IndexedDB Schema
Open your database with a versioned upgrade block. A clean starting schema looks like this:
const DB_NAME = "app_db";
const DB_VERSION = 1;
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Local data store
if (!db.objectStoreNames.contains("records")) {
const store = db.createObjectStore("records", { keyPath: "id" });
store.createIndex("updatedAt", "updatedAt", { unique: false });
}
// Outbox for unsynced mutations
if (!db.objectStoreNames.contains("outbox")) {
const outbox = db.createObjectStore("outbox", {
keyPath: "outboxId",
autoIncrement: true,
});
outbox.createIndex("status", "status", { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Every outbox entry should carry: the HTTP method, the target endpoint, the payload, a timestamp, and a status field (pending, inflight, or failed).
Writing to the Outbox First
When a user submits data, forget the API call entirely. Write to IndexedDB first, update the local UI optimistically, and then attempt a sync. This is the inversion that makes offline-first work.
async function submitRecord(data) {
const db = await openDB();
const tx = db.transaction(["records", "outbox"], "readwrite");
const record = { ...data, id: crypto.randomUUID(), updatedAt: Date.now(), syncStatus: "pending" };
tx.objectStore("records").put(record);
tx.objectStore("outbox").add({
method: "POST",
endpoint: "/api/records",
payload: record,
timestamp: Date.now(),
status: "pending",
});
await tx.done;
attemptSync(); // fire and forget
}
The user sees immediate feedback. The data is safe. The network is a background concern.
The Sync Engine
Your sync engine runs in a Service Worker using the Background Sync API where available, and falls back to an online event listener for environments that do not support it.
// In your service worker
self.addEventListener("sync", (event) => {
if (event.tag === "outbox-sync") {
event.waitUntil(flushOutbox());
}
});
// Fallback in app context
window.addEventListener("online", () => attemptSync());
flushOutbox reads all pending entries from the outbox, marks them inflight, fires the requests sequentially (not in parallel — sequential ordering preserves causality), and on success removes them from the outbox and updates the local record's syncStatus to synced.
Sequential processing matters. If a user created a record and then updated it while offline, firing those two requests in parallel or out of order can corrupt server state.
Conflict Resolution — The Part Most Tutorials Skip
When the sync fires, the server may have changed since the user went offline. You need a strategy before you need code.
Last-write-wins (LWW) is the simplest: the most recent updatedAt timestamp wins. It is acceptable for user-owned records that no one else edits, but it silently discards changes in collaborative contexts.
Server-wins is appropriate for reference data — product catalogs, pricing, configuration. On sync, if the server returns a 409 Conflict, discard the local change and pull the server version.
Three-way merge is the right choice for collaborative records. Store a baseVersion snapshot alongside the local mutation. On conflict, diff the base-to-local delta against the base-to-server delta and merge non-overlapping fields automatically. Surface genuinely overlapping field conflicts to the user.
For most SaaS products built in Ghana and across Africa, a pragmatic hybrid works well: LWW for user-owned records, server-wins for shared reference data, and a simple "your change was overridden" notification for edge-case collisions.
Handling Low-Bandwidth Gracefully
Offline-first is not only about zero connectivity — it is also about degraded connectivity. On a 2G connection, a large sync payload will time out and retry, hammering the server.
Mitigate this with:
- Payload batching — group multiple outbox entries into a single
POST /api/batchrequest to reduce round-trips - Exponential backoff with jitter — do not retry immediately on failure; wait 2s, then 4s, then 8s with random jitter to avoid thundering herd
- Delta sync — rather than re-sending full records, send only changed fields with a
fieldMask - Compression — enable
Content-Encoding: gzipon your API and use the Compression Streams API in the service worker for request bodies
A well-tuned sync engine can complete a backlog of 50 queued mutations on a slow EDGE connection in under three seconds.
Testing Your Offline Logic
Chrome DevTools' Network panel lets you simulate offline, slow 3G, and custom throttle profiles. But do not stop there — write integration tests that:
- Open the app, disable the network, perform mutations
- Inspect IndexedDB state to confirm outbox entries exist
- Re-enable the network and assert the outbox drains
- Query your test server to confirm server state matches local state
Cypress with the cy.intercept command and an IndexedDB inspection utility covers this well.
Why This Matters for Your Project
If you are building a SaaS product, a field-data collection tool, or any mobile-facing application intended for markets where connectivity is variable — and most African markets qualify — offline-first is not a nice-to-have. It is a retention and trust feature. Users who lose data once rarely return. An architecture that writes locally first, syncs intelligently, and resolves conflicts without surprising the user is the difference between an app people rely on and one they abandon at the first dropped signal. Build the outbox pattern in from day one; retrofitting it into an online-only architecture is significantly more painful than starting with it.





