Offline-First Is a Conflict Resolution Problem

Table of Contents
- Caching Is Not Offline Support
- The Local Database Is the Source of Truth
- Queueing Mutations Correctly
- Conflicts Are Inevitable
- Choosing a Resolution Strategy
- Identifiers Before the Server Sees Them
- What the User Must Be Told
- Testing Offline Behaviour
- Common Pitfalls
- Conclusion
- Frequently Asked Questions
Key takeaway: Offline-first means the local database is authoritative and the network is a synchronisation detail. The hard part is not storing data locally — it is deciding, per data type, what happens when two devices modify the same record.
Caching Is Not Offline Support
Many applications described as offline-capable are online applications with a cache. They display previously-fetched data when the network is unavailable and refuse to accept changes.
That is genuinely useful and it is a different thing. The user can read and cannot act, which for most applications means the offline experience is a read-only museum of their data.
Offline-first inverts the relationship. The application reads and writes to a local database, always, with no network involvement in the interaction path. Synchronisation happens in the background, whenever connectivity permits. From the user’s perspective there is no offline mode, because the application behaves identically either way.
The architectural consequence is significant. Every write must be accepted locally and applied optimistically, then reconciled with the server later. Which means every write can potentially conflict with a change made elsewhere, and the application needs an answer for that — per data type, decided deliberately.
That reconciliation logic is the actual work. The local storage is a solved problem with well-supported libraries; the conflict semantics are domain-specific and cannot be delegated to a library.
The Local Database Is the Source of Truth
The structural change that defines offline-first: the user interface reads from local storage exclusively and never waits for the network.
Conventional: UI → network request → server → response → UI
Offline-first: UI → local database → UI
↑ background sync ↕ server
Consequences worth understanding before adopting this:
Every read is fast and local. This is a performance benefit independent of connectivity, and it is frequently the reason teams adopt the pattern even for well-connected users.
Every write succeeds immediately. The user sees their change applied without waiting, which changes the interaction feel substantially.
The local schema requires migration management. It is a real database on a device you do not control, running versions of your application you cannot force-update. Schema migrations must handle upgrades from any previously-shipped version.
Data volume needs bounding. Devices have limited storage. Syncing everything is not viable for large datasets, so partial synchronisation with defined scope is necessary.
Sync state must be modelled explicitly. Every record needs to express whether it is synced, pending, or in conflict. This is data the user interface consumes, so it belongs in the schema rather than in memory.
That last item is where implementations commonly go wrong. Sync state held only in memory is lost when the application is killed, which on mobile happens routinely and without warning. Pending mutations must survive process death, which means persisting them.
Queueing Mutations Correctly
Changes made offline accumulate in a queue that must be applied to the server later. Several properties determine whether this works.
Persistence across process death. The operating system terminates backgrounded applications freely. A queue in memory loses the user’s work, which is the worst possible failure for an offline-first application.
Ordering where it matters. Some operations depend on earlier ones — creating a record then updating it. The queue must preserve that order, or collapse the pair into a single operation.
Idempotency. A mutation may be sent, succeed on the server, and have its response lost. On retry, the server must recognise the duplicate. A client-generated idempotency key on every mutation is what makes this safe, and it is not optional.
Collapsing redundant operations. Five edits to the same field while offline should send one final value rather than five sequential updates. This reduces payload, reduces conflict surface, and is straightforward to implement.
Handling permanent failures. A mutation the server rejects for a business reason — the record was deleted, validation fails, permission was revoked — cannot be retried indefinitely. It needs to be surfaced to the user with a decision.
That final case is the one most often unhandled, and it produces the worst symptom: a queue that retries forever, silently, while the user believes their change was saved.
Conflicts Are Inevitable
Two devices, both offline, both modify the same record. Both come online. This is not an edge case — it is the normal consequence of accepting offline writes, and it occurs whenever a user has more than one device or collaborates with anyone.
Detection requires version information. The options:
Version numbers. Each record carries an integer incremented on each server-side change. A mutation includes the version it was based on, and the server rejects it if the current version differs. Simple and reliable.
Timestamps. Simpler and unreliable, because device clocks are wrong. Client-generated timestamps can be minutes or hours off, which produces incorrect ordering decisions.
Vector clocks. Track causality properly across replicas, permitting accurate detection of genuinely concurrent changes. More complex and correct in cases the alternatives get wrong.
Content hashes. Detect that a change occurred without revealing what changed.
For most applications, server-authoritative version numbers are the right default: they are simple, correct for the common cases, and require no clock trust.
The important design point is that detection is separate from resolution. Detecting a conflict tells you two changes happened concurrently. What to do about it is a domain decision, and it differs per data type within the same application.
Choosing a Resolution Strategy
Different data warrants different handling, and applying one strategy uniformly produces bad outcomes somewhere.
| Strategy | Behaviour | Appropriate for |
|---|---|---|
| Last write wins | Most recent change survives | Low-value fields, user preferences |
| Server wins | Server state overwrites local | Server-computed or authoritative data |
| Client wins | Local change overwrites server | Data the local user solely owns |
| Merge per field | Non-overlapping field changes both apply | Records with independent fields |
| Append-only | Both changes retained as separate entries | Logs, comments, activity streams |
| Prompt the user | Present both and let them decide | High-value content the user authored |
| CRDT | Automatic convergence by data structure | Collaborative text and lists |
Last-write-wins deserves specific caution because it is the default choice and it silently discards data. For a preference toggle that is fine. For a document the user spent twenty minutes editing, losing their work without telling them is a serious product failure.
Field-level merging is substantially better than record-level replacement for most structured data, and it requires tracking changes per field rather than per record. That is more work at write time and it converts many conflicts into non-conflicts — two users editing different fields of the same record is extremely common and need not be a conflict at all.
Conflict-free replicated data types are the right answer for genuinely collaborative editing, where convergence must be automatic and correct. They carry metadata overhead and constrain the data model, so they suit the specific case rather than general use.
The practical approach: decide per data type, document the decision, and default to preserving data over discarding it when uncertain.
Identifiers Before the Server Sees Them
A record created offline needs an identity immediately, before any server has assigned one. This has consequences that ripple through the design.
The approach that works: generate identifiers on the client using a scheme that avoids collisions — UUIDs, or an identifier incorporating a device identifier. The client-generated identifier becomes the permanent identity, and the server accepts it rather than assigning its own.
The alternative — a temporary local identifier replaced by a server identifier on sync — creates substantial complexity. Every reference to the record must be rewritten. Any pending mutation referencing the temporary identifier must be updated. And if the user creates related records offline, the whole graph needs remapping.
Client-generated identifiers avoid all of that, at the cost of the server not controlling its own key space. For most applications that trade is clearly worthwhile, and time-ordered identifier schemes address the index locality concern that databases have with random keys.
A related requirement: deletion needs care. A record deleted on one device and edited on another produces a conflict that a simple deletion cannot express. Soft deletion with a tombstone, retained long enough for all devices to sync, is the standard answer. Hard deletion means an edit arriving later either resurrects the record or fails confusingly.
What the User Must Be Told
Offline-first changes what the user needs to understand, and getting this wrong undermines trust in the application.
Sync status per item, not globally. A single global indicator cannot express that three items synced and one failed. Per-record state is what users need to know whether their specific change is safe.
Distinguish pending from failed. Pending is normal and requires no action. Failed requires a decision. Presenting both identically means users either worry unnecessarily or ignore genuine problems.
Surface conflicts when they need a decision. Where the resolution strategy is to prompt, the prompt must be findable and must show both versions clearly enough to choose between them.
Never silently lose data. If a change cannot be applied, say so. Discarding a user’s work without acknowledgement is the failure that destroys confidence in an application permanently.
Make the sync state honest. An indicator showing success when a mutation is queued rather than confirmed is a lie that will eventually be discovered.
The general principle: users tolerate delay and tolerate being asked to resolve a conflict. They do not tolerate work disappearing without explanation, and an offline-first application that loses data occasionally will be trusted less than an online-only one that simply refuses to work.
Testing Offline Behaviour
Offline-first correctness is difficult to verify because the interesting cases involve timing and concurrency.
Airplane mode is insufficient. It tests the fully-disconnected case, which is the easy one. Intermittent connectivity, slow connections, and connections that accept requests then fail are where the bugs live.
Test process death with a pending queue. Kill the application with unsynced mutations and verify they persist and eventually apply. This is the failure that loses user data and it is easy to miss.
Simulate concurrent modification deliberately. Two clients, both modify the same record offline, both sync. Verify the resolution matches the documented intent for that data type.
Test the duplicate-response case. A mutation that succeeds server-side with a lost response, then retries. Confirm idempotency actually works rather than assuming it.
Test schema migration from every shipped version. Users skip updates. A migration path that only handles the previous version will corrupt data for anyone upgrading from further back.
Test with realistic data volumes. Sync behaviour with ten records and ten thousand differs substantially, particularly on first sync.
The first-sync case deserves separate attention. A new device downloading a large dataset over a poor connection, possibly interrupted, must resume rather than restart. Implementations that work well in steady state frequently handle initial sync badly.
Common Pitfalls
Mutation queue in memory only. Lost on process death, which loses user work.
Client timestamps for conflict detection. Device clocks are wrong.
Last-write-wins applied uniformly. Silently discards data that mattered.
Server-assigned identifiers. Requires remapping references across the whole local graph.
Hard deletion. Edits arriving after a delete have no coherent handling.
Global sync indicator only. Cannot express per-item failure.
Testing only airplane mode. Misses intermittent connectivity, the harder case.
Conclusion
Offline-first is an architecture where the local database is authoritative and synchronisation is a background concern. The storage layer is the straightforward part; the difficulty is deciding what happens when the same record changes in two places.
Make those decisions explicitly and per data type. Field-level merging turns many apparent conflicts into non-conflicts. Append-only semantics suit activity data. Prompting suits high-value authored content. Last-write-wins is acceptable for preferences and dangerous for anything a user worked on.
Then get the mechanics right: persist the mutation queue so process death does not lose work, generate identifiers on the client so no remapping is needed, use soft deletion so late-arriving edits have somewhere to go, and attach idempotency keys so retries are safe.
And communicate honestly. Per-item sync state, a clear distinction between pending and failed, and never discarding a change without telling the user. An offline-first application that occasionally loses data is trusted less than one that simply refuses to work offline.
Frequently Asked Questions
Which local database should be used? Any embedded database with reactive queries, so the interface updates automatically when local data changes. The specific choice matters far less than modelling sync state in the schema.
How is conflict resolution chosen? Per data type, based on what the data means. Preferences can use last-write-wins. Authored content should prompt or merge. Activity data should append. Uniform strategies produce bad outcomes somewhere.
Are CRDTs necessary? Only for genuinely collaborative editing where automatic convergence is required. They add metadata overhead and constrain the data model, so most applications do better with explicit per-type strategies.
How much data should sync to the device? What the user plausibly needs, bounded by storage. Recent and frequently-accessed data, with older data fetched on demand. Full replication is only viable for small datasets.
How are schema migrations handled on devices? Versioned migrations that run on upgrade, tested from every previously-shipped version. Users skip updates, so the migration path must handle large version jumps.
Should client-generated identifiers be used? Yes, in nearly all cases. Server-assigned identifiers require remapping every local reference on sync, which is substantially more complex than accepting a client-generated one.
What is the most commonly missed requirement? Persisting the mutation queue. Losing queued changes when the operating system terminates the application is the failure that loses user work, and it is easy to overlook because it does not occur during normal testing.



