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:

Code
             Application UI
                   |
                   v
               Repository
                   |
          +--------+--------+
          |                 |
          v                 v
     Local Database    Sync Engine
          |                 |
          |                 v
          |             Network API
          |                 |
          |                 v
          |            Remote Database
          |                 |
          +--------+--------+
                   |
                   v
            Conflict Resolver

The 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:

C#
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:

C#
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:

  1. Create a customer record.
  2. Update the customer's phone number.
  3. Add a note to the customer.
  4. 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:

C#
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:

JSON
{
  "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:

Code
PUT /api/customers/C1001
If-Match: "10"
Content-Type: application/json

If 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.

JSON
{
  "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.

Conflict-Free Replicated Data Types

Conflict-Free Replicated Data Types, commonly called CRDTs, provide data structures designed to merge concurrent updates deterministically.

They are useful when multiple devices must modify shared data independently and later converge without requiring a central conflict decision for every update.

Common examples include:

  • Grow-only counters.
  • Add-wins sets.
  • Remove-wins sets.
  • Distributed registers.
  • Collaborative text structures.

A grow-only counter can combine increments from multiple devices without losing them.

For example, if Device A records three completed tasks and Device B records two completed tasks independently, a suitable counter structure can merge both contributions.

CRDTs are not a universal replacement for business conflict resolution. They require carefully selected data types and merge semantics. A CRDT that works for a counter may not be appropriate for inventory reservations or financial transactions.

Implement Synchronization With a Server Version Check

A simple synchronization endpoint can use optimistic concurrency to protect remote records.

The following ASP.NET Core example illustrates the basic approach:

C#
[HttpPut("{id:guid}")]
public async Task<IActionResult> UpdateCustomer(
    Guid id,
    UpdateCustomerRequest request,
    CancellationToken cancellationToken)
{
    var customer = await dbContext.Customers
        .FirstOrDefaultAsync(
            x => x.Id == id,
            cancellationToken);

    if (customer == null)
    {
        return NotFound();
    }

    if (customer.Version != request.ExpectedVersion)
    {
        return Conflict(new
        {
            Message = "The customer was updated by another device.",
            CurrentVersion = customer.Version,
            CurrentPhone = customer.Phone,
            CurrentCity = customer.City
        });
    }

    customer.Phone = request.Phone;
    customer.City = request.City;
    customer.Version++;

    await dbContext.SaveChangesAsync(cancellationToken);

    return Ok(customer);
}

The request model could be:

C#
public class UpdateCustomerRequest
{
    public string Phone { get; set; } = string.Empty;

    public string City { get; set; } = string.Empty;

    public int ExpectedVersion { get; set; }
}

This example demonstrates the version comparison, but a production implementation needs an atomic concurrency guarantee.

A database concurrency token, conditional update, or appropriate transaction should ensure that two simultaneous requests cannot both read version 10 and successfully update it to version 11.

For example, Entity Framework Core supports optimistic concurrency through concurrency tokens. When configured correctly, it can detect when another transaction has modified the record before the current update is saved.

The API can then return a conflict response and allow the synchronization client to retrieve the latest version.

Synchronize in the Background

Synchronization should not require users to keep the application open.

A background worker can process pending operations when network connectivity is available.

A typical synchronization cycle looks like this:

SQL
              Local Changes
                    |
                    v
              Sync Queue
                    |
                    v
             Check Connectivity
                    |
              +-----+-----+
              |           |
            Offline      Online
              |           |
              v           v
           Wait       Send Pending
                         Changes
                           |
                           v
                    Check Response
                           |
                +----------+----------+
                |                     |
             Accepted               Conflict
                |                     |
                v                     v
          Mark Synced           Resolve Conflict
                |                     |
                +----------+----------+
                           |
                           v
                    Pull Remote Changes
                           |
                           v
                    Update Local Store

The worker should use bounded retries with exponential backoff for temporary network and server failures.

For example, a failed operation might be retried after progressively longer delays rather than being sent continuously.

Permanent errors, such as invalid data or insufficient permissions, should not be retried indefinitely. They should be recorded for investigation or presented to the user when appropriate.

For Android applications, WorkManager provides persistent background work with network constraints. In Flutter, background execution depends on the target platform and available plugins. In web applications, Background Sync can help where supported, but applications should also synchronize when the user reopens the application because background execution is not guaranteed.

Use Change Tracking to Avoid Downloading Everything

A synchronization engine should not download the complete database every time the application reconnects.

Instead, the server can expose incremental changes using a cursor, sequence number, or change token.

For example:

Code
GET /api/sync/changes?since=10450

The server returns changes made after the specified position:

JSON
{
  "nextCursor": 10478,
  "changes": [
    {
      "id": "C1001",
      "version": 12,
      "type": "updated"
    },
    {
      "id": "C1002",
      "version": 4,
      "type": "created"
    }
  ]
}

The client stores the returned cursor only after the corresponding changes have been successfully persisted locally.

This prevents the application from advancing its synchronization position while silently missing records.

The server should also define how long change history is retained. If a device remains offline longer than the retention period, it may need a full synchronization rather than continuing from an expired cursor.

For systems with relational data, incremental synchronization must account for relationships, deletions, and ordering dependencies. A customer record should not be synchronized without the related data required to interpret it correctly.

Handle Deletions and Duplicate Operations

Two areas frequently overlooked in offline-first systems are deletion and duplicate processing.

Deletion tracking

Suppose a user deletes a customer while offline. If the application simply removes the local record, the synchronization engine may have no information indicating that the remote record should also be deleted.

A common solution is a tombstone.

Instead of immediately removing the record, the application marks it as deleted and synchronizes that state.

C#
public class Customer
{
    public Guid Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public bool IsDeleted { get; set; }

    public int Version { get; set; }
}

The server can process the deletion and later remove obsolete records according to a retention policy.

Tombstones are important because an older device may reconnect and attempt to upload a previous version of a record that was already deleted elsewhere.

Duplicate operation handling

Network failures can occur after a server processes an operation but before the client receives the response.

The client therefore cannot always know whether the operation succeeded.

Use unique operation IDs and server-side idempotency records to ensure that retrying the same operation does not create duplicate business transactions.

This is particularly important for orders, payments, inventory movements, and other operations with financial or operational consequences.

Test Offline and Conflict Scenarios

Testing only successful online synchronization is insufficient.

An offline-first application should be tested under different connectivity and concurrency conditions.

Scenario

Expected behavior

User creates a record offline

Record is stored locally and queued for synchronization

Connection is restored

Pending operations are uploaded

Two devices modify different fields

Changes are merged when business rules permit

Two devices modify the same field

Conflict policy is applied

Server accepts a request but response is lost

Retrying does not duplicate the operation

User deletes a record offline

Deletion is synchronized using a tombstone or equivalent mechanism

Device remains offline for several days

Application recovers using its synchronization policy

User loses access during synchronization

Unauthorized changes are rejected and handled safely

Also test application termination during synchronization. The application should be able to restart without losing queued operations or applying the same change twice.

For business-critical workflows, test conflicts involving money, inventory, permissions, and workflow state separately. These operations usually require stricter rules than ordinary profile or preference updates.

Common Mistakes in Offline-First Development

Mistake

Why it causes problems

Better approach

Treating local and remote databases as automatically consistent

Changes can diverge while devices are offline

Maintain explicit synchronization state

Using timestamps as the only conflict mechanism

Device clocks can be incorrect

Use server versions or reliable ordering metadata

Overwriting the complete remote record

Independent changes can be lost

Use field-level or domain-specific merging

Retrying operations without idempotency

Duplicate business transactions may occur

Assign unique operation IDs

Deleting local records immediately

Remote deletion may never be communicated

Use tombstones or deletion events

Downloading all data after every reconnect

Wastes bandwidth and processing time

Use incremental synchronization

Assuming connectivity means internet access

A device may have a network connection without reaching the server

Handle actual request failures

Applying Last Write Wins to every entity

Important business changes can disappear

Choose conflict rules based on the data's meaning

Conclusion

Offline-first architecture allows applications to remain useful when network connectivity is unreliable, but it introduces the responsibility of reconciling independently modified data.

A reliable implementation needs durable local storage, a synchronization queue, version tracking, incremental data exchange, and a conflict-resolution strategy that reflects the business rules.

Last Write Wins can work for simple preferences, field-level merging can preserve independent changes, and CRDTs can support specific collaborative data structures. Critical business records may require explicit user intervention rather than automatic resolution.

The most important design decision is to treat synchronization and conflict resolution as core application capabilities, not as background tasks added after the local database is implemented.