An application that depends entirely on a stable internet connection can become difficult to use in environments where connectivity is unreliable. Field employees may work in remote locations, sales teams may visit customers with limited network access, and mobile users may move between connected and disconnected networks throughout the day.
Offline-first architecture addresses this problem by allowing an application to read and modify locally stored data without waiting for the server. Changes are synchronized when connectivity becomes available again.
However, storing data locally is only part of the challenge. The difficult situation occurs when multiple users or devices modify the same information while disconnected.
For example, two sales representatives may update the same customer record from different devices. Both changes are valid from the perspective of their local applications, but the server must eventually reconcile them.
This is where conflict resolution becomes essential.
A reliable offline-first application needs a clear synchronization strategy, a way to detect concurrent changes, and rules for deciding whether changes should be merged automatically or reviewed by a user.
Understand the Offline-First Architecture
An offline-first application treats local storage as the primary source for application reads. The interface does not need to wait for a network request every time a user opens a screen.
A typical architecture contains four major components:
- Local database: Stores application data on the device.
- Repository: Coordinates local and remote data sources.
- Synchronization engine: Transfers changes between the device and server.
- Conflict resolver: Handles changes that cannot be merged automatically.
The architecture can be represented as:
Application UI
|
v
Repository
|
+--------+--------+
| |
v v
Local Database Sync Engine
| |
| v
| Network API
| |
| v
| Remote Database
| |
+--------+--------+
|
v
Conflict ResolverThe user interacts with locally available data. The repository saves changes locally and marks them for synchronization.
When connectivity returns, the synchronization engine sends pending changes to the server and retrieves remote updates.
The conflict resolver determines what should happen when the local and remote versions cannot be safely combined.
This separation prevents UI components from becoming responsible for network retries, database reconciliation, and conflict handling.
Store Data Locally Before Synchronizing
An offline-first application should not require a successful network request before confirming a local change.
Consider a task management application. A user creates a task while disconnected from the internet.
The application should save the task locally and display it immediately. The synchronization engine can upload it later.
A simplified data model might look like this:
public class TaskItem
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public bool IsCompleted { get; set; }
public DateTime UpdatedAt { get; set; }
public string? ServerVersion { get; set; }
public bool IsPendingSync { get; set; }
}The IsPendingSync property indicates that a local change has not yet been acknowledged by the server.
A repository can save the task locally:
public async Task CreateTaskAsync(
TaskItem task,
CancellationToken cancellationToken)
{
task.Id = Guid.NewGuid();
task.UpdatedAt = DateTime.UtcNow;
task.IsPendingSync = true;
await localDatabase.SaveAsync(
task,
cancellationToken);
}The application can display the saved task without waiting for the server.
This example illustrates the local-write pattern. A production implementation should use a durable local transaction and an operation queue or transactional outbox so that the data change and its synchronization record cannot become inconsistent.
If the application saves the task but crashes before recording the pending operation, the server may never receive the change.
Design a Reliable Synchronization Queue
A synchronization queue records operations that need to be sent to the remote service.
For example, a user might perform the following operations while offline:
- Create a customer record.
- Update the customer's phone number.
- Add a note to the customer.
- Mark a follow-up task as completed.
Each operation should have a unique identifier and enough information to process it safely.
A simple synchronization record might contain:
public class SyncOperation
{
public Guid OperationId { get; set; }
public Guid EntityId { get; set; }
public string EntityType { get; set; } = string.Empty;
public string OperationType { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public int RetryCount { get; set; }
}The operation ID is particularly important because network requests can fail after the server has already processed them.
For example, the server may create a customer record successfully, but the response may be lost because the device disconnects.
If the client sends the same request again, the server could create a duplicate record unless it supports idempotency.
A robust synchronization API should recognize previously processed operation IDs and return the existing result rather than applying the operation twice.
This is especially important for operations involving payments, orders, inventory, and other business transactions.
Detect Conflicts Using Version Information
A conflict occurs when a local change and a remote change are based on an earlier version of the same data.
Consider a customer record:
{
"id": "C1001",
"name": "Rahul Sharma",
"phone": "9876543210",
"city": "Patna",
"version": 10
}Two devices download version 10.
Device A changes the phone number while offline. Device B changes the city while offline.
When both devices synchronize, the server receives two updates based on version 10.
Without conflict detection, the second update could overwrite the first update.
One common solution is optimistic concurrency control.
The client sends the version it originally read. The server accepts the update only if the current version still matches.
For example, an API might use an HTTP conditional request:
PUT /api/customers/C1001
If-Match: "10"
Content-Type: application/jsonIf the server has already moved to version 11, it rejects the stale update with HTTP 412 Precondition Failed.
The client then retrieves the latest record and determines whether the changes can be merged.
For APIs using HTTP entity tags, the server should return an ETag representing the current resource version. The client sends that value in If-Match when updating the resource.
This prevents a client from unknowingly replacing a newer version.
Choose the Right Conflict Resolution Strategy
There is no single conflict resolution algorithm suitable for every application. The correct approach depends on the data model and the business consequences of losing or combining changes.
Last Write Wins
Last Write Wins selects one update based on an ordering rule, often a timestamp or version assigned by the server.
For example:
Device | Updated field | Recorded time |
|---|---|---|
Device A | Phone number | 10:05 AM |
Device B | City | 10:08 AM |
If the application uses a document-level Last Write Wins strategy, Device B's complete document may replace Device A's version.
This can silently discard the phone number update.
Last Write Wins is useful for data where replacing an older value is acceptable, such as certain preference settings or temporary status information.
However, client timestamps are not always reliable. Device clocks can be incorrect or out of sync. A server-assigned ordering mechanism is generally safer when the application needs a deterministic winner.
Field-Level Merge
Field-level merging combines independent changes when they affect different fields.
In the customer example:
- Device A updates phone.
- Device B updates city.
The merged result can preserve both changes.
{
"id": "C1001",
"name": "Rahul Sharma",
"phone": "9123456780",
"city": "Gaya"
}This works well when fields are independent and their updates do not violate business rules.
However, field-level merging is not always safe.
Suppose one device changes an order quantity while another changes its unit price. Combining the fields independently may produce a financial result that neither user intended.
The merge operation should therefore understand the domain rather than blindly combining JSON properties.
Manual Conflict Resolution
Some changes should never be overwritten automatically.
For example, if two employees modify the same contract terms while offline, the application may need to display both versions and request a decision from an authorized user.
A conflict record can include:
- Original version.
- Local changes.
- Current server version.
- User or device identity.
- Conflict detection time.
- Available resolution actions.
The interface can then allow the user to keep the server version, keep selected local changes, or create a merged version.
This strategy is appropriate when data accuracy is more important than automatic synchronization.
Join the conversation! Your thoughts help the community grow.