DEV Community

Cover image for How to Design an Offline-First Web App with IndexedDB and Conflict Resolution
Vic Tekys for Devico Solutions

Posted on

How to Design an Offline-First Web App with IndexedDB and Conflict Resolution

An inspector finishes a site audit from a van with no signal. The form validates, the app says "saved," she drives off. Two hours later the device reconnects, the queue flushes, and one of two things happens: a colleague's older edit to the same record — synced first, because his van hit coverage earlier — silently overwrites hers, or the server rejects the audit on a rule the form never checked, and she finds out a week later, when the context is gone.

Both failures come from the same misunderstanding. The moment your client accepts a write without the server, it stops being a cache. It is a replica with its own history, and the design problem is what happens when two histories meet.

So my thesis: offline-first is a data-modeling decision, not a caching layer. The change log, the replay contract and the merge rule are the work. IndexedDB — the part most tutorials spend all their time on — is the easy part.

Decide what "offline" means per screen, not per app

The most expensive sentence in an offline project is "the app has to work offline." All of it? Every screen I've built lands in one of three classes:

  1. Read-only offline. Catalogues, price lists, 3D models, reference data. A service worker and a cache solve this — nobody writes, so nothing conflicts.
  2. Write-offline. Inspections, orders, audits, stock counts. The only class that needs a change log, replay and a merge rule.
  3. Online-only. Payments, identity changes, anything with an external side effect — an email, a printed label, a card charge. Making these offline creates a duplicate-side-effect problem you cannot undo.

Only the middle class costs real money, so scoping it down is the biggest cost lever you have.

For scale, the industry mostly hasn't got past class one. The 2025 Web Almanac reports service workers on 20.5% of desktop sites, up roughly tenfold since 2022 — but only 12% of PWA sites' service workers even handle fetch. Most are installed for lifecycle events and install prompts, not offline behaviour of any kind.

The example from our work is a PWA for Castrol that authorizes and monitors the partner workshops selling its products, with both offline and online modes as a hard requirement. Authorizing a workshop is an audit workflow — the user records findings in the field. That is a class-two write path by definition, and it dictated the whole architecture below.

The limitation: the boundary moves. A read-only price list is class one until someone edits a quantity on it. Re-classify on every feature, not once at kickoff.

Store intent, not state

The hack persists final state: the user edits an inspection, you write the new object to IndexedDB, and on reconnect you PUT it. A snapshot cannot be merged — the server sees a whole object and can't tell which fields were touched, from what version, or in what order.

The architecture persists intent: an append-only log of mutations, with local state derived from replaying it.

What belongs in the change log

Each entry carries enough to be replayed, rebased, partially rejected, and shown to a user:

interface ChangeLogEntry {

opId: string;          // client-generated UUID — the idempotency key

seq: number;           // local monotonic sequence, for ordering

entity: 'inspection' | 'workshop' | 'finding';

entityId: string;      // client-generated for creates, too

op: 'create' | 'update' | 'delete';

baseVersion: number | null;  // server version this was made against

payload: Record;  // only the fields touched

createdAt: number;     // for display only — never for ordering

status: 'pending' | 'sent' | 'acked' | 'rejected' | 'conflict';

}

Three fields do the heavy lifting:

  • opId lets the server dedupe a replayed operation.
  • baseVersion lets the server detect that the record moved while the client was offline — the basis of conflict detection.
  • payload holding only touched fields is what makes per-field merge possible later.

On Castrol, the architecture kept all data in sync with local IndexedDB on the frontend, with the ability to log changes and new entries so the system could be updated as soon as the user reconnected, plus a merge-conflict resolution mechanism on top. The stack was React and Redux, Dexie.js over IndexedDB, Koa on Node.js, and CosmosDB on Azure. The case study doesn't say why Dexie or CosmosDB were picked, so I won't invent a rationale — the log-plus-merge shape is the point.

Two costs you sign up for:

  • Logs grow. Once an entry is acknowledged, fold it into the local snapshot and drop it — but keep the snapshot's version.
  • Replaying old entries against a schema that has since changed is a migration problem. The log format needs a version field too.

IndexedDB is a database — treat it like one

Most IndexedDB pain comes from treating it as a key-value bag. What I hold to:

  1. Versioned schemas and upgrade transactions. Dexie's design doc is blunt: a version with an upgrader attached must never be altered — further changes go in a new version declaration, and the upgrade function for version 3 transforms data as it existed in version 2.
  2. Real indexes for the queries you actually run. Dexie stores any property but only queries indexed ones. statusand seq on the change log matter, because "pending ops in order" is the query on every reconnect.
  3. One transaction per unit of work. A create that touches inspections and appends to changelog is one transaction, or you will eventually get a logged intent with no entity, or the reverse.
  4. A wrapper over the raw event API. Dexie auto-commits a transaction once you stop doing database work in it, and awaiting a non-Dexie promise inside one ends it — the failure everyone hits once.

import Dexie, { type Table } from 'dexie';

class FieldDB extends Dexie {

inspections!: Table;

changelog!: Table;

constructor() {

super('field');

this.version(1).stores({

inspections: 'id, workshopId, version',

changelog: 'opId, seq, status, [status+seq]',

});

}

}

export async function recordUpdate(db: FieldDB, id: string, patch: Partial) {

return db.transaction('rw', db.inspections, db.changelog, async () => {

const current = await db.inspections.get(id);

if (!current) throw new Error(unknown inspection ${id});

await db.inspections.update(id, patch);

await db.changelog.add({

opId: crypto.randomUUID(),

seq: await nextSeq(db),

entity: 'inspection',

entityId: id,

op: 'update',

baseVersion: current.version,

payload: patch,

createdAt: Date.now(),

status: 'pending',

});

});

}

Two things to look at: the compound index [status+seq] is the reconnect query, and baseVersion is captured at write time, not sync time.

Quota, eviction and the cache you thought was durable

Client storage is a budget, not a disk, and someone else sets the budget.

  • Per MDN's quota documentation, Chromium gives an origin up to 60% of total disk, Firefox's best-effort mode caps a site group at the smaller of 10% of disk or 10 GiB, and Safari on macOS 14 / iOS 17 allows roughly 60% of disk for browser apps but only about 15% for embedded web views.
  • Under storage pressure, browsers evict best-effort origins least-recently-used first, and eviction is all-or-nothing: IndexedDB, Cache API and OPFS for that origin go together.
  • Safari's tracking prevention deletes script-written storage from any origin the user hasn't touched in seven days of browser use — though web.dev notes the cap doesn't apply to installed PWAs.
  • A committed transaction isn't necessarily on disk. IndexedDB's durability option treats "relaxed" data as committed once handed to the OS, while "strict" waits for the storage medium. For a change log, ask for strict.

Two of our projects show storage discipline with large payloads. Vitus, a Danish constructability platform, stores 3D models locally in IndexedDB as part of a package that also moved to SVF2, parallel loading, web workers and list virtualization; the team reports models 5x smaller and loading roughly 2.5x faster — for the bundle, not IndexedDB alone, and Vitus is not offline-first. On a fashion-tech sales platform for in-store kiosks, the previous app cached so much media locally it ran out of memory; the rebuild moved to a central media service with caching and preloading plus Workbox, and reports a 95% smaller local cache, memory crashes eliminated, and 40% faster startup. That's storage hygiene, not offline sync.

The rule that follows: local writes are durable only until acknowledged, never the system of record. Losing the entire local store should lose at most the unsynced tail — and the user should be able to see how long that tail is.

Replay is a distributed-systems problem

Reconnection is not "retry the failed POSTs." It is a batch write from a stale replica, with all the failure modes of one. The contract I use:

  1. The client sends pending operations in seq order, each with its opId and baseVersion.
  2. The server processes each operation independently and returns a per-operation verdict: accepted (with the new version), rejected (with a reason), or conflict (with current server state).
  3. The client marks each entry by verdict and never discards the batch because one entry failed.

type Verdict =

| { opId: string; status: 'ok'; version: number }

| { opId: string; status: 'rejected'; code: string; message: string }

| { opId: string; status: 'conflict'; serverState: unknown; serverVersion: number };

async function replay(db: FieldDB, api: SyncApi) {

const pending = await db.changelog

.where('[status+seq]').between(['pending', Dexie.minKey], ['pending', Dexie.maxKey])

.toArray();

if (!pending.length) return;

const verdicts = await api.push(pending);   // same opIds every attempt

for (const v of verdicts) {

await db.changelog.update(v.opId, { status: v.status === 'ok' ? 'acked' : v.status });

if (v.status === 'conflict') await parkForResolution(db, v);

}

}

The batch is re-sent with the same opIds on every attempt. That is what makes three scenarios survivable:

  • Partial-batch failure. Five of twelve operations applied before the connection dropped. With per-op verdicts, the client knows which seven to resend; without them, it resends all twelve and the server must dedupe.
  • The tab that reconnects twice. Two tabs, both detect online, both flush. Same opId, so the server applies each once. Take a navigator.locks lock around replay anyway, so they don't also fight over the local log.
  • Two devices on one account. Phone and laptop, both with pending edits to one record. That isn't a replay bug, it's a merge — the merge-rule section below.

Idempotency and the duplicate-record bug

The naive replay outcome is the duplicate: a retried create that produces two records. Stripe's design is the reference I copy: the server saves the status code and body of the first request for each idempotency key — success or failure — returns the same result on every replay, errors if the parameters differ, and prunes keys after at least 24 hours. Your sync endpoint needs exactly that table, keyed on opId.

This bug class doesn't need an offline app to appear. Sway, a US logistics company running an online system, hit race conditions in trip creation where processing delays assigned parcels to the wrong trips; the fix was queue functionality that eliminated the unnecessary trip creation, with 20% less stored data reported as a result. Sway never had an offline client — I cite it because if concurrent creation misbehaves with milliseconds between requests, it will with hours of buffered writes arriving at once.

The limitation of strict ordering: one poisoned operation — a create the server will never accept — blocks everything behind it, so you need to park an entry (status rejected, out of the replay path, shown to the user) and let the rest drain. And background delivery is uneven: the Background Sync API sits at 76.7% global support with no Firefox or Safari implementation, so on iOS replay only runs while the app is open.

Validation has to run twice

If the client only checks required fields and the server enforces the real rules, offline users fill forms that fail on replay — when they can no longer see the site, the workshop or the part number they were describing. The client must apply the server's rules at write time.

The way to do that without maintaining two rule sets is one schema, two runtimes. On Castrol that was JSON Schema with the same validation applied on the client and the backend, using AJV on both sides.

// shared/schemas/inspection.json is imported by both the React app and the Koa API

import Ajv from 'ajv';

import inspectionSchema from '../shared/schemas/inspection.json';

const ajv = new Ajv({ allErrors: true });

export const validateInspection = ajv.compile(inspectionSchema);

// client, at write time — before anything hits the change log

if (!validateInspection(draft)) {

return showErrors(validateInspection.errors);

}

What travels and what doesn't:

  • Travels: types, ranges, required fields, enums, cross-field rules within one record.
  • Stays server-side: uniqueness, quotas, permissions — anything depending on state the client doesn't have.

So rejections still happen. Design the rejection UI — the parked entry, the reason, the "edit and resubmit" path — instead of pretending the shared schema made them impossible.

Auth when the token expires offline

The access token expires at 09:00, the device is offline from 08:30 to 16:00, and the user is locked out of data already on the device. There is nothing to refresh against. Two decisions have to be made explicitly:

  1. What may an expired session still do locally? My default: read everything cached, write to the change log, block replay until re-authentication. Offline work continues; nothing reaches the server under a dead session.
  2. How are credentials held on the device? Castrol's approach was to encrypt credentials on the fly using dynamically generated RSA keys, store the encrypted credentials in the user's JWT with the public key on the backend, and generate a fresh key pair on every token refresh, on top of OAuth2. The case study doesn't say why this design over alternatives, so treat it as one working shape, not a recommendation.

Whatever you hold locally, follow RFC 9700, the January 2025 OAuth 2.0 Security Best Current Practice: refresh tokens for public clients must be sender-constrained or rotated on every use.

The honest limitation: any credential usable offline weakens revocation. A lost device stays useful until it reconnects and the server gets to say no. You can shorten the window — local PIN or biometrics, wipe the store after N days offline — but you cannot close it. Say so in the design review instead of presenting it as solved.

Choosing a merge rule you can defend

The rule most teams start with is last-write-wins on a client timestamp. It is wrong for offline in a specific way: the device that was offline longest often carries the newest wall-clock time, because it was still being edited after the others synced. LWW then rewards the stalest replica. And the clocks aren't trustworthy anyway — Airship's analysis of its mobile event stream (from 2016, so older data) found about 2% of devices with clocks three or more minutes off. A merge rule built on those clocks is arbitrary with extra steps.

Ranked by cost, the options I'd actually consider:

  1. Server-authoritative rejection. If baseVersion doesn't match the current version, reject and return the server state. Cheap, correct, annoying — the user re-does the edit. The right default for anything money- or compliance-shaped.
  2. Per-field merge on disjoint fields. The change log carries only touched fields, so if two edits touched different fields, apply both; conflict only on the same field. This resolves most real conflicts on form-shaped data for very little code.
  3. Domain-specific merge. Append-only collections merge trivially (findings on an inspection are a set, not a document). Counters travel as deltas, never absolutes — "+3 units" merges, "quantity = 12" doesn't.
  4. CRDTs. Real convergence without coordination, at real cost. Kleppmann and colleagues' local-first paper (2019) is upfront that CRDTs store all history, that performance and memory became a problem in their own prototypes, and that the one change they cannot resolve is concurrent updates to the same property. The engineering has improved: Automerge 2.0 (2023) stores a ~260k-keystroke editing trace in 129 KB against 107 KB of plain text, Automerge 3.0 (July 2025) reports a 10x memory cut, and the crdt-benchmarks suite puts the same trace at 160 KB for Yjs with a 39 ms parse against 1.8 s for Automerge 2.1. Those are text-editing numbers; for a form with forty fields, the library is a dependency you carry for a problem you may not have.
  5. User-mediated resolution. Show both versions, let the human choose. Most correct, most expensive — the UI has to exist for every conflicting entity type.

LWW is defensible in exactly one case: single-user-per-record data, ordered by server-assigned timestamps on arrival. Then it's just "the server applies operations in the order it receives them," which is fine.

The limitation that applies to every option, CRDTs included: convergence is about data, not business rules. Two replicas can converge perfectly on an order whose delivery date precedes its creation date. Invariants still get checked on the server after the merge, and you're back to the rejection UI.

None of the case studies above published which merge rule they used — Castrol's mechanism is described only as merge-conflict resolution. The ranking is mine, from building and rebuilding this a few times.

What offline-first costs

I'd rather you hear this from me than from your on-call rotation:

  • The write path roughly doubles: local apply plus log, then server apply plus verdict.
  • Schema migrations happen on two sides, and the client has to migrate a log written by an older version of itself.
  • Every bug becomes a distributed one. Reproducing it means reconstructing a device's log, in order, against the server state it saw. Ship an "export my change log" button on day one.
  • Storage is a budget you don't control, and eviction is silent.
  • Revocation has a hole the size of the offline window.

If your users don't have real intermittent connectivity — a warehouse with one dead aisle, a clinic basement, a van on a rural route — you don't need this. An optimistic UI with a retry queue covers flaky office Wi-Fi at a tenth of the work.

If they do, this is the order of effort:

  1. Scope offline per screen, and fight every flow out of the write-offline class that you can.
  2. Log intent, not state, with client-generated IDs and base versions.
  3. Make replay idempotent with per-operation verdicts, and give a poisoned entry somewhere to go.
  4. Share validation as one schema in both runtimes, then design the rejection UI for what doesn't travel.
  5. Decide the merge rule per entity, write it down, and reach for CRDTs only where convergence without a server is a requirement rather than a preference.

And the unglamorous half: conflicts you don't design for still get resolved. Just arbitrarily, by whichever device reconnected last — and the user finds out weeks later, from a colleague, that their inspection isn't there.

Top comments (0)