Every sync engine eventually meets the same fork in the road: the same diary was edited in two places, and now there are two versions. The literature offers a menu — operational transforms, CRDTs, three-way merge prompts — and at the bottom, almost apologetically, sits last-write-wins: take the newer timestamp, drop the other, move on. It reads like the option you pick when you've given up on doing it properly.
Storyie is a diary and notes app. People write on a phone on a train, lose signal, keep typing, and sometimes open the same entry on a second device. We chose last-write-wins on purpose — but the version we shipped is not "compare two timestamps, newest wins." That naive form quietly makes several decisions on your behalf and gets at least one of them wrong. This post is about the four choices hiding inside LWW, and what apps/expo/services/conflictResolver.ts actually does about each one.
TL;DR
- LWW is not a cop-out — it's four decisions. What counts as a conflict, how much clock drift to forgive, who wins a tie, and whether to notify the user.
- A conflict is a stale base, not "two edits."
checkForConflictonly flags a conflict when the server moved since our last sync and we have unsynced local changes. - One second of clock drift is forgiven.
TIMESTAMP_TOLERANCE_MS = 1000— sub-second device-vs-server skew is treated as a tie, not an ordering. - Ties go to the server, on purpose. Within the tolerance window,
determineWinnerprefers the server: it's the version every other device already agreed on. - The real guard is at the SQLite layer.
upsertDiaryFromApirefuses to apply a server pull over an entity with a pending local mutation — so a background sync can't clobber an edit you just made. - LWW is not silent-write-wins. Resolution returns
shouldNotifyUser: truewith a plain-language message. The loser is always told.
A conflict is a stale base, not "two edits"
The first decision is the one most naive implementations skip: when is there even a conflict? If you resolve on every difference between local and server, you'll "resolve" constantly — every routine pull looks like a disagreement. The interesting question isn't "do these differ," it's "did they diverge from a common point."
checkForConflict encodes that. It compares three timestamps, not two — the local edit time, the server's current update time, and the server time we last synced from:
// apps/expo/services/conflictResolver.ts (excerpt)
const localMs = new Date(localTimestamp).getTime();
const serverMs = new Date(serverTimestamp).getTime();
const lastSyncedMs = lastSyncedServerTimestamp
? new Date(lastSyncedServerTimestamp).getTime()
: 0;
// No conflict if server hasn't changed since our last sync
if (serverMs <= lastSyncedMs) {
return { hasConflict: false, winner: null, localTimestamp, serverTimestamp };
}
// Server has changed since our last sync - conflict exists
// Determine winner using Last-Write-Wins
const winner = determineWinner(localMs, serverMs);The serverMs <= lastSyncedMs check is the whole idea. If the server's updated_at hasn't advanced past the version we already have on file, then whatever we're holding locally is a straightforward continuation — we can push it, no conflict. A conflict only exists when the server moved underneath us: another device wrote after our last pull, and we also have local edits. That's a stale base, and it's the only case worth resolving.
The docstring in the code states the same rule as a two-part condition — a conflict needs both server.updated_at > local.serverUpdatedAt and local changes (syncStatus !== 'synced'). Narrowing "conflict" to exactly this case is what keeps the resolver quiet during normal operation and loud only when it matters.
One second of clock drift is a product tolerance, not a bug
Once you're comparing a device clock against a server clock, you've inherited a hardware problem: they are never exactly in sync. A phone whose clock runs 400ms fast would "win" LWW comparisons it has no business winning, silently burying genuinely newer server edits under the drift.
So Storyie doesn't compare to the millisecond. It forgives a full second:
// apps/expo/services/conflictResolver.ts (excerpt)
/**
* Threshold for considering timestamps as "equal"
* Accounts for clock drift between client and server (1 second)
*/
export const TIMESTAMP_TOLERANCE_MS = 1000;One second is a chosen number, not a physical constant. It's long enough to absorb the realistic skew between a consumer phone and our API, and short enough that a real human edit made a few seconds later still orders correctly — nobody types a sentence, syncs, and types the next one inside a 1-second window by accident. Pick the tolerance too small and clock noise decides your conflicts; too large and you start dropping legitimately-ordered edits into the "tie" bucket. A second is where those two failure modes are both quiet.
Ties go to the server, on purpose
The tolerance window creates a third decision that a millisecond comparison never has to make: what happens inside the window? When two edits land within a second of each other, LWW has no meaningful "newest" to point at. Someone has to win anyway.
determineWinner breaks the tie toward the server:
// apps/expo/services/conflictResolver.ts (excerpt)
function determineWinner(localMs: number, serverMs: number): ConflictWinner {
const diff = localMs - serverMs;
// If within tolerance, prefer server for safety
if (Math.abs(diff) <= TIMESTAMP_TOLERANCE_MS) {
return "server";
}
// Otherwise, most recent timestamp wins
return diff > 0 ? "local" : "server";
}The comment — prefer server for safety — is the product call. The server holds the version other devices have already converged on, so preferring it keeps every device heading toward the same state; preferring local would let whichever device synced most recently quietly pull the shared truth in its own direction. It's also the more recoverable mistake: if the server wrongly wins, the user's local text is still right there on screen and one more save re-asserts it. If local wrongly won, the server edit is simply gone. Given a coin-flip, we take the side we can walk back.
The unit test pins this behavior so it can't drift: two timestamps 500ms apart resolve to "server", not to whichever happens to be numerically larger.
// apps/expo/services/__tests__/conflictResolver.test.ts (excerpt)
it("should prefer server when timestamps are equal (within tolerance)", () => {
// ...
// When timestamps are within tolerance, prefer server for safety
expect(result.hasConflict).toBe(true);
expect(result.winner).toBe("server");
});The SQLite write-back guard: never LWW away an uncommitted edit
Here's the failure the timestamp math can't catch on its own. A background pull is applying server rows to the on-device database at the exact moment the user is editing an entry that hasn't been pushed yet. The local edit is real and newer, but from the pull's point of view the incoming server row is just the authoritative latest. Resolve purely on timestamps and you can overwrite work the user is still doing.
Storyie closes this at the lowest layer — the SQLite upsert itself. Before upsertDiaryFromApi writes an incoming server row, it checks whether that entity still has a queued local mutation, and bails if so:
// apps/expo/lib/db/queries/diaries.ts (excerpt)
export async function upsertDiaryFromApi(row: { /* ...server row... */ }): Promise<void> {
// LWW write-back guard — do not overwrite uncommitted local changes.
if (await hasPendingSyncForEntity("diary", row.id)) return;
const db = await getLocalDb();
// Tombstone guard (#1366): a queued delete op can end in status `failed`
// (retries exhausted), which clears the pending-sync guard above but must
// still stop this write-back from resurrecting the local tombstone. A
// genuinely newer server-side edit (from another device) still wins.
const [existing] = await db
.select({ deletedAt: localDiaries.deletedAt, updatedAt: localDiaries.updatedAt })
.from(localDiaries)
.where(eq(localDiaries.id, row.id))
.limit(1);
if (existing?.deletedAt != null && existing.updatedAt >= row.updatedAt) return;
// ...onConflictDoUpdate with the server row...
}This is why the sync pull loop can treat the server as ground truth without fear. In pullSinceCursor, every incoming upsert flows through this function, and the comment at the call site says so plainly: upsertDiaryFromApi already skips rows with pending local mutations. The timestamp resolver decides who wins a genuine conflict; this guard decides when it's even safe to apply a server row. Two layers, two different jobs.
The tombstone guard stacked on top (issue #1366) is a reminder that these rules accrete from real incidents, not from theory: a delete whose retries were exhausted clears the pending-sync flag, so without the extra deletedAt check a stale server row could resurrect an entry the user deleted offline. LWW's "newest wins" still holds — a genuinely newer edit from another device is allowed through — but a stale write-back against a local tombstone is refused.
Last-write-wins is not silent-write-wins
The final decision is the one that separates a trustworthy auto-merge from a spooky one: when a conflict is resolved and a version is discarded, does the user find out? Storyie's answer is always yes. resolveConflict returns not just the winning content but an instruction to surface what happened:
// apps/expo/services/conflictResolver.ts (excerpt)
export const CONFLICT_MESSAGES = {
LOCAL_WINS: "Your local changes were kept. The server version was older.",
SERVER_WINS: "The server had newer changes. Your local version was updated.",
NO_CONFLICT: "No conflict detected.",
} as const;
// ...inside resolveConflict, when the server wins:
return {
winner: "server",
resolvedContent: serverData.content,
shouldNotifyUser: true,
message: CONFLICT_MESSAGES.SERVER_WINS,
};Note the asymmetry: a no-conflict resolution returns shouldNotifyUser: false — routine syncs stay invisible — but any real resolution, local-wins or server-wins, sets it true. We only interrupt the user when their model of "what's in this entry" might now be wrong.
This is a deliberate narrowing of an earlier ambition. The original design note (ADR-0011) imagined a three-way merge prompt for important fields. In practice, a merge dialog in a diary app is a lot of UI for a rare event, and it asks the user to adjudicate a conflict they mostly don't care about. What shipped is smaller and, we'd argue, better calibrated: resolve automatically with a defensible rule, then tell the user which way it went and why, in one sentence. Automatic where it can be, transparent where it can't be silent.
Takeaways
Last-write-wins gets dismissed as the lazy conflict strategy, but "newest timestamp wins" is the least interesting part of it. The decisions that actually shape the user's experience are the ones around it:
- Define conflict narrowly — a stale base, not any difference — so the resolver is quiet in normal operation.
- Forgive clock drift explicitly. A tolerance constant is a product choice about how much you trust two clocks; comparing to the millisecond is a choice too, just a worse one.
- Decide the tie deliberately. "Prefer the server" converges every device and keeps the recoverable failure mode.
- Guard uncommitted work at the storage layer, below the timestamp logic, so a pull can never race an unpushed edit.
- Never resolve silently. The one-sentence notification is what makes an automatic merge feel like a feature instead of a bug.
The companion to this piece is Local-First Autosave Without Losing the Last Keystroke, which covers the other half of the problem — getting every keystroke durably onto the device in the first place, before any of this conflict machinery ever runs.