An offline-first application can complete synchronization successfully and still produce the wrong result.
The API may return 200 OK.
The local mutation queue may become empty.
The device may show that all changes are synchronized.
And yet, one valid update may have silently overwritten another.
This is one of the most dangerous offline-first failures because nothing appears broken technically.
There is no crash.
There is no network error.
There is no failed request.
The system behaves exactly as implemented.
The problem is that the implementation never defined which version of the data should win.
The Failure Scenario
Consider a maintenance application used by field technicians and supervisors.
A technician opens a maintenance request while working in an area with poor connectivity.
The current request contains:
{
id: "REQ-1042",
status: "OPEN",
priority: "MEDIUM",
notes: "Initial inspection pending",
version: 4
}
The technician goes offline and updates the request:
{
status: "IN_PROGRESS",
notes: "Inspection started at the site"
}
Before the technician reconnects, a supervisor opens the same request through a web dashboard and changes the priority:
{
priority: "HIGH",
notes: "Potential safety risk reported"
}
Now two valid versions exist.
The technician’s device contains one valid update.
The server contains another valid update.
When the device reconnects, the mobile application sends its pending mutation.
The difficult question is not:
How do we upload the local data?
The difficult question is:
Which version should be trusted?
Why Last-Write-Wins Is Not Always Conflict Resolution
A common implementation compares timestamps and keeps the latest update.
This is known as last-write-wins.
For some fields, this may be acceptable.
For example:
- a profile description
- a temporary draft
- a non-critical display preference
- an editable note where losing an earlier version is acceptable
But last-write-wins becomes dangerous when the conflicting data includes:
- approval status
- inspection findings
- financial values
- compliance records
- safety-related decisions
- healthcare information
- task ownership
- inventory quantities
Imagine that the technician’s update is submitted five seconds after the supervisor’s update.
A timestamp-based strategy may replace the supervisor’s high-priority safety warning with the technician’s older local copy.
The synchronization request succeeds.
The server accepts the payload.
The local queue is cleared.
But an important update has been lost.
That is not a networking failure.
It is an architecture failure.
Offline-First Is a Business-Rules Problem
Offline-first architecture is often described using technical components:
- SQLite or Realm
- local persistence
- mutation queues
- retries
- background synchronization
- connectivity listeners
- optimistic UI updates
These components are important.
But they only solve the mechanics of storing and transporting data.
They do not decide what should happen when two correct versions disagree.
That decision belongs to business rules.
Before building the synchronization engine, every important field or operation should have a conflict-resolution policy.
A useful starting point is to define four possible outcomes:
- Merge
- Reject
- Overwrite
- Review
1. Merge
Use merging when both updates can safely coexist.
Suppose the server contains:
{
tags: ["electrical"]
}
The offline device adds:
{
tags: ["urgent"]
}
Instead of replacing one array with the other, the system can merge them:
{
tags: ["electrical", "urgent"]
}
Append-only activity logs are another good candidate for merging.
type ActivityEvent = {
id: string;
requestId: string;
type: string;
createdAt: string;
createdBy: string;
};
Each action becomes a separate immutable event.
The system adds new events instead of replacing the complete history.
This makes synchronization safer because concurrent actions do not compete for ownership of the same record.
2. Reject
Some fields should be controlled only by the server.
For example:
{
approvedBy: "manager-id",
approvedAt: "2026-07-12T10:30:00Z",
complianceStatus: "VERIFIED"
}
A mobile device should not be allowed to overwrite these values from an old local snapshot.
If an offline mutation contains server-owned fields, the backend should reject those parts of the update.
A field ownership model may look like this:
const fieldOwnership = {
approvedBy: "server",
approvedAt: "server",
complianceStatus: "server",
notes: "shared",
description: "shared",
localDraft: "client"
} as const;
The client can still display server-owned fields while offline.
But it should not treat them as locally authoritative.
3. Overwrite
Overwriting is acceptable when the business explicitly allows one side to win.
For example, a user may edit a local draft on multiple devices, and the product may intentionally use the latest saved version.
The important point is that overwrite should be a documented rule, not an accidental side effect.
Instead of saying:
The latest request happened to win.
The architecture should say:
This field uses last-write-wins because losing the earlier value is acceptable.
That difference matters.
An accidental overwrite is a bug.
An intentional overwrite is a policy.
4. Review
Some conflicts cannot be resolved safely by software.
Consider these two changes:
// Technician update
{
inspectionResult: "SAFE"
}
// Supervisor update
{
inspectionResult: "REQUIRES_REVIEW"
}
Automatically choosing either version may create operational risk.
The correct outcome may be to preserve both versions and require a person to resolve the conflict.
A conflict record could look like this:
type SyncConflict = {
entityId: string;
field: string;
localValue: unknown;
serverValue: unknown;
localUpdatedAt: string;
serverUpdatedAt: string;
resolutionStatus: "PENDING" | "RESOLVED";
};
The application can show a dedicated conflict-resolution screen:
Inspection result changed in two places.
Local version:
SAFE
Server version:
REQUIRES_REVIEW
Choose a value or escalate for supervisor review.
This is less convenient than automatic synchronization.
But convenience should not override correctness for critical data.
Use Version Numbers, Not Only Timestamps
Timestamps are useful, but they are not always reliable enough for concurrency control.
Devices may have incorrect clocks.
Requests may be delayed.
Two updates may happen within the same timestamp precision.
A stronger approach is optimistic concurrency control using version numbers.
The server stores:
{
id: "REQ-1042",
status: "OPEN",
version: 4
}
The client sends the version it originally edited:
PATCH /maintenance-requests/REQ-1042
If-Match: 4
The payload may contain:
{
"status": "IN_PROGRESS"
}
If the server still has version 4, the mutation can be accepted.
The server updates the record to version 5.
If the server already has version 5, the client edited an outdated copy.
The server can return a conflict response:
409 Conflict
Example response:
{
"code": "VERSION_CONFLICT",
"currentVersion": 5,
"serverRecord": {
"id": "REQ-1042",
"status": "OPEN",
"priority": "HIGH",
"version": 5
}
}
The client now has enough information to merge, reject, overwrite or request human review.
A Simple React Native Mutation Queue
An offline mutation should contain more than an endpoint and payload.
It should also preserve the version that the user originally edited.
type PendingMutation = {
id: string;
entityType: "maintenanceRequest";
entityId: string;
operation: "UPDATE";
baseVersion: number;
changes: Record<string, unknown>;
createdAt: string;
retryCount: number;
};
Example mutation:
const mutation: PendingMutation = {
id: "mutation-501",
entityType: "maintenanceRequest",
entityId: "REQ-1042",
operation: "UPDATE",
baseVersion: 4,
changes: {
status: "IN_PROGRESS",
notes: "Inspection started at the site"
},
createdAt: new Date().toISOString(),
retryCount: 0
};
During synchronization:
async function syncMutation(mutation: PendingMutation) {
const response = await fetch(
`/maintenance-requests/${mutation.entityId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
"If-Match": String(mutation.baseVersion)
},
body: JSON.stringify(mutation.changes)
}
);
if (response.status === 409) {
const conflict = await response.json();
return {
type: "CONFLICT",
mutation,
serverRecord: conflict.serverRecord
};
}
if (!response.ok) {
throw new Error(`Sync failed with status ${response.status}`);
}
return {
type: "SUCCESS",
record: await response.json()
};
}
The important part is that a 409 Conflict should not be treated like a normal retryable network error.
Retrying the same request repeatedly will not resolve a semantic conflict.
The system needs a conflict-resolution rule.
Classify Sync Failures Correctly
Not every failed synchronization attempt means the same thing.
A useful classification is:
Network failure
Examples:
- no connectivity
- request timeout
- DNS failure
- temporary server unavailability
Action:
- retry with backoff
Authentication failure
Examples:
- expired token
- revoked session
- missing permission
Action:
- refresh credentials or require authentication
Validation failure
Examples:
- invalid field value
- required field missing
- unsupported state transition
Action:
- show the user what must be corrected
Version conflict
Examples:
- server record changed after the local copy was created
- two users edited the same field
- an approval occurred while the device was offline
Action:
- merge, reject, overwrite or review
Treating all four categories as “sync failed” creates weak architecture and confusing user experiences.
Do Not Delete the Local Mutation Too Early
A dangerous synchronization pattern looks like this:
await sendMutation(mutation);
await removeMutationFromQueue(mutation.id);
This is safe only when the server has confirmed that the mutation was accepted and correctly applied.
The mutation should remain available when:
- the server returns a conflict
- only part of the payload was accepted
- conflict review is pending
- the response cannot be validated
- the application crashes before local state is updated
A safer state machine might be:
type MutationStatus =
| "PENDING"
| "SYNCING"
| "CONFLICT"
| "FAILED"
| "COMPLETED";
The queue entry should move to COMPLETED only after the client has processed the final server response.
Conflict Resolution Should Be Field-Aware
Record-level conflict resolution is often too broad.
Suppose the local version changes:
{
notes: "Inspection completed"
}
At the same time, the server changes:
{
priority: "HIGH"
}
These updates affect different fields.
Rejecting the entire local mutation would be unnecessary.
A field-aware merge can preserve both changes:
{
notes: "Inspection completed",
priority: "HIGH"
}
But consider this case:
// Local
{
status: "COMPLETED"
}
// Server
{
status: "CANCELLED"
}
Both updates affect the same field.
The conflict policy for status must now decide whether to:
- reject the local transition
- keep the server value
- ask for review
- apply a domain-specific state machine
This is why conflict resolution must understand business meaning, not only JSON structure.
Define the Rules Before Selecting the Database
Teams often begin offline-first discussions with questions such as:
- Should we use Realm?
- Should we use SQLite?
- Should we use WatermelonDB?
- How should we structure the retry queue?
- Which connectivity library should we use?
These are useful implementation questions.
But they come after more important architecture questions:
- Which fields are client-owned?
- Which fields are server-owned?
- Which operations are append-only?
- Which fields can use last-write-wins?
- Which conflicts require human review?
- How long should unresolved conflicts be retained?
- Can users continue editing while a conflict is pending?
- What audit trail is required?
A database can persist conflicting data.
It cannot decide which version is correct.
A Practical Conflict-Policy Table
Before implementation, document the policy for important fields.
| Field or Operation | Ownership | Resolution Strategy | Human Review |
|---|---|---|---|
| User notes | Shared | Last-write-wins or merge history | No |
| Approval status | Server | Reject local overwrite | Yes |
| Activity log | Append-only | Merge | No |
| Inspection result | Shared | Compare versions | Sometimes |
| Compliance status | Server | Server wins | Yes |
| Draft description | Client | Local overwrite allowed | No |
| Assigned technician | Server | Reject stale update | Yes |
This table becomes a shared contract between:
- mobile developers
- backend developers
- product managers
- QA engineers
- security teams
- compliance stakeholders
Without this contract, each layer may implement a different interpretation of “sync.”
Questions to Ask During Architecture Review
Before calling an application offline-first, ask:
- What happens when local and server records both change?
- Does every mutation include a base version?
- Which fields are controlled by the server?
- Which data can be merged safely?
- Which fields may use last-write-wins?
- Which conflicts must be reviewed by a person?
- Is conflict history preserved for auditing?
- Can the user see when synchronization is blocked?
- Are semantic conflicts separated from network failures?
- When is a mutation permanently removed from the queue?
If the team cannot answer these questions, the application may support offline storage, but it does not yet have a complete offline-first architecture.
Final Takeaway
Offline-first architecture is not only about local databases, queues and retry mechanisms.
Those components answer:
How will data move when connectivity returns?
Conflict policies answer the more important question:
What should happen when two valid versions disagree?
Before building the synchronization engine, define four possible outcomes:
Merge. Reject. Overwrite. Review.
Make those decisions per field or operation.
Add version numbers to mutations.
Keep unresolved conflicts visible.
And never assume that a successful API response means the correct data survived.
Which field in your application would be dangerous to resolve using last-write-wins?
Top comments (0)