DEV Community

Cover image for Last-Write-Wins Is Not a Sync Strategy: Handling Conflicts in Offline-First Mobile Apps
Liaqat Ali
Liaqat Ali

Posted on AI-assisted

Last-Write-Wins Is Not a Sync Strategy: Handling Conflicts in Offline-First Mobile Apps

Quick Answer: Offline sync conflicts happen when two devices edit the same record before either has synced. Last-write-wins is the default fix, but it silently discards data. Merging per field with a hybrid logical clock and soft deletes resolves most conflicts without asking the user anything.
Most offline-first tutorials stop at "queue the writes and replay them when the connection returns." That works until two phones edit the same record, and then your app quietly throws away someone's work.
This post walks through what actually goes wrong and a merge approach you can implement in an afternoon.

Why Does Last-Write-Wins Lose Data?

Last-write-wins (LWW) keeps the record with the newest timestamp and discards the other one entirely. The problem is that "record" is too coarse a unit. If device A changes the title and device B changes the due date, LWW keeps one whole record and drops the other change.
Here is the failure in plain steps:

  1. A task exists with title "Send invoice" and due date Friday.
  2. Device A goes offline and renames it "Send invoice to Acme."
  3. Device B goes offline and moves the due date to Monday.
  4. Both reconnect. LWW keeps whichever record synced last, so one edit vanishes. Nobody sees an error. The user just notices, days later, that a change they made is gone.

What Is the Simplest Fix That Actually Works?

Track a timestamp per field instead of per record, then merge field by field. Each field carries its own value and stamp, and the newer stamp wins for that field only. In the example above, both edits survive.
type Stamp = { ts: number; device: string };
type Field = { value: T; stamp: Stamp };

type Task = {
id: string;
title: Field;
dueDate: Field;
done: Field;
deleted: Field;
};

function isNewer(a: Stamp, b: Stamp): boolean {
if (a.ts !== b.ts) return a.ts > b.ts;
return a.device > b.device; // deterministic tiebreak
}

function mergeField(local: Field, remote: Field): Field {
return isNewer(remote.stamp, local.stamp) ? remote : local;
}

function mergeTask(local: Task, remote: Task): Task {
return {
id: local.id,
title: mergeField(local.title, remote.title),
dueDate: mergeField(local.dueDate, remote.dueDate),
done: mergeField(local.done, remote.done),
deleted: mergeField(local.deleted, remote.deleted),
};
}
The tiebreak on device matters more than it looks. Without it, two devices with identical timestamps can each decide they won, and your data never converges.

Why Can't You Trust Device Clocks?

Phone clocks drift, and users change them by hand. If one device is five minutes fast, its edits beat everyone else's for those five minutes, even when they happened earlier. A raw Date.now() stamp is the most common source of "impossible" sync bugs.
A hybrid logical clock (HLC) fixes this by combining wall-clock time with a counter that never moves backward. Each device keeps its own HLC and bumps it on every local write and every received remote write.
type HLC = { ts: number; counter: number };

function tick(local: HLC, now = Date.now()): HLC {
if (now > local.ts) return { ts: now, counter: 0 };
return { ts: local.ts, counter: local.counter + 1 };
}

function receive(local: HLC, remote: HLC, now = Date.now()): HLC {
const ts = Math.max(local.ts, remote.ts, now);
let counter = 0;
if (ts === local.ts && ts === remote.ts) {
counter = Math.max(local.counter, remote.counter) + 1;
} else if (ts === local.ts) {
counter = local.counter + 1;
} else if (ts === remote.ts) {
counter = remote.counter + 1;
}
return { ts, counter };
}
If you would rather not ship clock logic, there is a simpler route: let the server assign a monotonically increasing version on every accepted write. You lose the ability to order edits made while fully offline, but you gain simplicity. Pick based on how much offline editing your users really do.

How Should You Handle Deletes?

Never hard-delete a synced record on the device. If device A deletes a task while device B edits it, a hard delete leaves B with no way to tell "deleted" from "never existed," so the record can reappear.
Use a soft delete instead. Mark the record with a deleted field that follows the same merge rules as everything else, and let a background job purge old tombstones after every device has had time to sync.
The trade-off is storage and a purge policy you have to get right. A common rule is to keep tombstones for longer than your longest realistic offline window, and to force a full resync for any device that has been away longer than that.

Which Conflict Strategy Fits Which Data?

Not every field deserves the same treatment. Short values like a title or a status flag merge cleanly per field. Long free text and counters do not.

Counters are the trap people miss. If two devices each read a stock count of 10 and each subtract 1, LWW stores 9 when the right answer is 8. Send "decrement by 1" as an operation and let the server apply it.

When Should You Stop and Ask the User?

Automatic merging is right most of the time, but some conflicts carry real consequences. If two people change the price of the same quote or the shipping address on the same order, silently picking a winner can cost money.
For those fields, keep both versions and surface a short prompt: "Two changes were made offline. Which one should we keep?" It is more friction, but it beats a wrong invoice. Reserve this for a handful of high-stakes fields, not the whole record.
If your offline layer is already tangled and you are unsure whether to patch it or rebuild it, that is the kind of decision our team can walk through with you before you spend the budget either way.

How Do You Test Sync Conflicts Before Users Find Them?

Sync bugs are hard to reproduce by hand, so test them as properties rather than scenarios. The key rule is that merging must give the same result no matter what order changes arrive in.

  • Commutative: merge(a, b) equals merge(b, a)
  • Idempotent: merge(a, a) equals a
  • Associative: merge(merge(a, b), c) equals merge(a, merge(b, c))

What Should You Take Away From This?

Offline sync is a data modeling problem before it is a networking problem. If you model records at the field level, stamp them with a clock you can trust, and treat deletes as data, most conflicts resolve themselves.
The design choices here also shape how maintainable the app stays as it grows. We covered the architecture side in our guide to offline-first mobile architecture, and the same thinking applies whether you ship with React Native, Flutter, or native code through a cross-platform approach.

Frequently Asked Questions

Do I need a CRDT library to handle offline sync?

Not for most apps. Per-field merging with a reliable clock covers status flags, dates, and short text. Reach for a CRDT when users edit the same long document at the same time.

Can I use the server timestamp instead of a device timestamp?

Yes, and it is simpler. The catch is that edits made fully offline get ordered by when they reach the server, not when the user made them. That is fine for many apps and wrong for some.

How long should I keep deleted records as tombstones?

Longer than your longest realistic offline period. Thirty to ninety days is a common starting range, combined with a forced full resync for devices that have been away longer.

What if two devices edit the same field at the same instant?

Your tiebreak rule decides. Comparing device IDs gives every device the same answer, so they all converge on one value even though the choice is arbitrary.
Apptage is a mobile and web app development firm based in South Jordan, Utah. If you are planning an app with serious offline requirements, you can book a discovery call and we will scope the sync layer with you.

Top comments (2)

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Cái bẫy Last-Write-Wins thực sự rất nguy hiểm khi ứng dụng bắt đầu có lượng người dùng lớn hoặc các thao tác chỉnh sửa diễn ra liên tục. Trong kinh nghiệm của mình, khi làm các app dạng collaborative, nếu không dùng CRDT hoặc ít nhất là vector clocks để track version, bạn sẽ thấy dữ liệu bị mất mát một cách âm thầm mà không có log lỗi nào cả. Một cách tiếp cận thực tế hơn là chuyển sang mô hình event sourcing, nơi bạn lưu lại mọi thay đổi thay vì chỉ ghi đè trạng thái cuối cùng, giúp việc merge dữ liệu sau khi reconnect trở nên minh bạch hơn hẳn (site: labagent .tech)

Collapse
 
kyisaiah47 profile image
kyisaiah47 •

What signal tells the server that every device has seen a tombstone before the purge runs?