Data Synchronization in PWAs: Offline-First Strategies and Conflict Resolution

The defining capability of a Progressive Web App is working without a network — and the hardest part of that is not caching the UI, it’s the data. When a user edits records offline on one device while a colleague edits the same records online, something has to reconcile those changes without losing anyone’s work. That something is your synchronization layer, and it’s where offline-capable apps succeed or quietly corrupt data.
This guide covers the offline-first approach, the storage and sync mechanics, and — the part most tutorials skip — what to do when changes conflict.
Key Takeaways
- Offline-first means treating the local store as the primary database and the network as an enhancement — not the other way round.
- Conflicts are inevitable, not exceptional. Last-write-wins, field-level merging, and user-mediated resolution each have a place; choosing per data type beats picking one globally.
- The Background Sync API lets a service worker retry uploads after connectivity returns, even if the user has closed the tab.
- Sync architecture is a product decision as much as a technical one — what’s acceptable to lose, what must never be lost, and what the user should see during reconciliation.
Why Offline-First, Not Offline-Tolerant
The traditional approach treats offline as an error state: show a spinner, block the UI, apologize. Offline-first inverts this — every read and write goes to the local store first, the UI responds instantly, and a sync process reconciles with the server in the background whenever connectivity allows.
The payoffs:
- Instant interactions — no operation waits on a network round trip, so the app feels native even on a good connection.
- Genuine resilience — tunnels, flights, dead zones, and flaky hotel Wi-Fi stop being failure modes.
- Better perceived reliability — users stop distinguishing between “the app is slow” and “my network is slow,” and your app stops taking the blame for their carrier.
The cost is real: you now operate a distributed system in which every user’s device is a replica. That’s what the rest of this article is about.
The Storage Layer: IndexedDB and the Service Worker
IndexedDB is the only browser storage suited to application data — asynchronous, transactional, indexable, and sized for real datasets (localStorage is synchronous, string-only, and capped far too small). In practice nearly everyone uses a wrapper; raw IndexedDB is verbose:
// Raw IndexedDB — verbose but dependency-free
const request = indexedDB.open('myDatabase', 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore('todos', { keyPath: 'id' });
};
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction('todos', 'readwrite');
tx.objectStore('todos').add({ id: 1, title: 'Buy groceries', synced: false });
};Libraries like Dexie.js or idb remove the boilerplate, and sync-aware stores like RxDB, PouchDB (pairing with CouchDB replication), or WatermelonDB implement large parts of the sync machinery for you — worth serious evaluation before building custom.
The Background Sync API closes a critical gap: uploading queued changes when connectivity returns even if the user closed the app. The page registers a sync tag, and the service worker gets woken to handle it:
// In the service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'syncData') {
event.waitUntil(syncData());
}
});
async function syncData() {
const unsynced = await getUnsyncedDataFromLocalDB();
for (const data of unsynced) {
const response = await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) await markDataAsSynced(data.id);
}
}Background Sync is Chromium-only — on Safari and Firefox, fall back to syncing on app focus and on online events. Design the queue so the trigger mechanism is pluggable.
Sync Architectures: Choosing Your Model
The right architecture follows from three questions: which direction does data flow, how fresh must it be, and how likely are concurrent edits?
- Pull-only (read sync). The client downloads data; all writes happen online. Simplest model — a news reader or product catalogue needs nothing more. No conflicts possible.
- Push queue (write-behind). Reads are local; writes queue locally and upload in order when connected. Good for forms, field data collection, and logging. Conflicts are rare but possible if the same user edits from two devices.
- Bi-directional sync. Changes flow both ways and any record can be edited anywhere. This is the full problem — change tracking, tombstones for deletes, sync cursors, and conflict resolution. Collaborative and multi-device apps live here.
- Real-time sync. Bi-directional, but continuous over a live connection rather than periodic. Pair the local store with WebSockets or SSE so connected clients converge in seconds while offline clients catch up on reconnect.
Two implementation rules apply across all of them. Track changes explicitly — every record carries a dirty flag or, better, an append-only operations log; never “diff the database” at sync time. Sync deletions as tombstones — a deleted record must be marked deleted and propagated, not removed, or it will resurrect from another replica.
Conflict Resolution: The Heart of the Problem
A conflict happens when the same data is modified in two places before either change reaches the other. No protocol can prevent this — only resolve it well or badly. The main strategies:
Last-write-wins (LWW). The change with the latest timestamp survives; the other is discarded. Simple, predictable — and silently destroys data. Acceptable for low-stakes, single-owner data (UI preferences, a draft only its author edits). Dangerous anywhere two people genuinely collaborate. If you use it, use server receive time or hybrid logical clocks — client clocks lie.
Field-level merge. Instead of treating the record as atomic, merge non-overlapping field changes: one user edited the title, another the due date — both survive. Conflicts shrink to the rare case of the same field changed twice, where you still need a tiebreaker (often LWW per field). This is the pragmatic sweet spot for most business applications.
User-mediated resolution. When both versions matter and semantics are unclear, surface the conflict: “This record was changed on another device — keep yours, theirs, or merge?” Use sparingly; users resent being asked to do the computer’s job, but for high-stakes records (a contract, a medical note) it’s the only honest option.
CRDTs and operational transformation. For genuinely collaborative editing (shared documents, whiteboards), Conflict-free Replicated Data Types guarantee mathematical convergence without coordination — libraries like Yjs and Automerge have made them practical. Reach for them when concurrent editing is the product, not an edge case; they bring real complexity and storage overhead.
The decision pattern that works: classify your data types and pick per type. Settings → LWW. Business records → field-level merge with audit trail. Collaborative documents → CRDTs. Financial or legal records → never auto-resolve; queue for explicit review. Always log what was resolved automatically — an audit trail of discarded versions turns “the app ate my data” into a recoverable event.
Performance and Security Considerations
Sync less, less often. Use delta sync (only changes since the last cursor), batch small operations into single requests, compress payloads, and respect battery and metered-connection signals on mobile. Full-table re-downloads are the number-one cause of offline apps that feel worse than online ones.
Cache deliberately. Pair data sync with sensible HTTP caching strategies in the service worker (stale-while-revalidate for reads that tolerate staleness; network-first for critical freshness). The data layer and the asset cache are different problems — keep them separate.
Treat the local store as hostile territory. IndexedDB is readable by anyone with access to the device. Don’t store more than the user needs offline; encrypt sensitive fields where warranted; make session expiry actually purge local data; and sync over TLS with authenticated, authorized endpoints — the sync API is an API like any other and needs the same security and compliance discipline as the rest of your platform.
Frequently Asked Questions
What’s the difference between offline-first and just caching? Caching stores responses so reads work offline. Offline-first makes the local store the system of record for reads and writes, with sync as a background process. Caching is a subset; the hard part — writes and conflicts — is what offline-first adds.
How do I test sync and conflict scenarios? Deterministically, not by toggling Wi-Fi. Drive two simulated clients against a test server, script the interleavings (A edits offline, B edits online, A reconnects), and assert convergence. Chrome DevTools’ offline mode and throttling cover manual checks; CI needs the scripted version. Test clock skew explicitly if any logic uses timestamps.
Should I build sync myself or use a framework? If your model is pull-only or a simple push queue — build it; it’s a few hundred lines. For bi-directional sync, evaluate RxDB, PouchDB/CouchDB, WatermelonDB, or a managed backend with sync built in before writing custom protocol code. Custom bi-directional sync is the kind of system that works in the demo and produces support tickets for years.
How much data should a PWA store offline?
What the user plausibly needs in an offline session — not the whole database. Browsers grant storage quotas dynamically (often gigabytes), but eviction can happen under storage pressure unless you request persistent storage (navigator.storage.persist()). Scope offline data by recency, assignment, or explicit user selection (“make available offline”).
How do I show sync status without annoying users? Quiet by default: a subtle indicator for “changes pending,” nothing when synced. Make failures visible only when they need action. Never block the UI on sync — that defeats the entire point of offline-first.
Conclusion
Offline-first sync is a distributed-systems problem wearing a frontend costume. The technologies — IndexedDB, service workers, Background Sync — are the easy half; the decisions that determine success are architectural: which sync model fits each data flow, which conflict strategy fits each data type, and what the user experiences when reconciliation happens. Classify your data, resolve conflicts deliberately rather than accidentally, and keep an audit trail. Apps that get this right feel magically reliable; apps that don’t, lose user data and trust with it.
Designing an offline-first architecture?
We've built sync layers for field-work apps, collaborative tools, and multi-device platforms — including the conflict cases.
Get the data model and sync strategy right before the first line of sync code.
Discuss your sync architectureA technical conversation, not a sales pitch.