Last write wins is a decision, not a default
When two writes reach one record and no code decides between them, the policy is whichever ran last. Name the resolver so the choice becomes reviewable.
Two writes arrive for the same record. One came from the user editing a field; one came from a sync that pulled a server copy. Both are valid, both succeed, and the record ends up holding one of them.
Which one is not a decision anybody made. It is whichever write ran second.
The case this post is about
One note, edited offline on a phone and edited on the web an hour earlier, reaching the same record:
sealed interface Resolution<out T> {
data class UseLocal<T>(val value: T) : Resolution<T>
data class UseRemote<T>(val value: T) : Resolution<T>
data class Merged<T>(val value: T) : Resolution<T>
data class Conflicted<T>(val local: T, val remote: T) : Resolution<T> // surface it
}
Before that type existed the sync path called upsert(remote) and the note the user typed on a
train was replaced by a version they had never seen. Nobody chose that policy. It is what you get
when two writes reach one record and nothing in the code has an opinion.
This post follows that note, because the fourth case above is the one an implicit policy cannot represent — and it is the only one that does not lose anything.
The symptom
A user's edit disappears. They changed a field, the change appeared, they navigated away and back and it had reverted. Nothing failed, nothing was reported, and the value they typed is gone.
The usual first theory is that the save did not happen. It did — the write succeeded, the record was updated, and then a sync that was already in flight finished and wrote the server's older copy over it. Two correct operations, one outcome, no error anywhere.

The second thing is that it is intermittent in a way that defeats reproduction. Whether the edit survives depends on which write finishes last, which depends on network timing. The same sequence of user actions produces different outcomes on different runs, so the bug report is "sometimes my changes do not save" and the first three attempts to reproduce it succeed.
The third is that the code reads as though the question does not arise. There is a save path and a sync path. Each is short and clear. Neither mentions the other, because neither is aware the other exists — and that absence is not visible in either file.
The fourth is what happens when it is fixed locally. Somebody notices the sync overwriting edits and adds a guard: skip the sync write if a local edit is pending. That works for the case they saw and establishes a policy nobody wrote down, in one branch of one function, which the next person to touch either path will not know about.
Why the obvious fix failed
Lock the record during edits. Correct in a database and wrong here, because the writes are separated by a network round trip and an unknown amount of user think-time. A lock held for the duration of a user editing a form is a lock held for minutes, on a device that may go offline mid-way.
Timestamp the writes and take the newest. This is the first real attempt and it is more subtle than it looks. Whose clock? The device's clock is user-settable and the server's clock is not available at the moment of the local write. "Newest" is a comparison between two times measured by different instruments, which is the kind of thing that works until somebody's phone is forty seconds off.
Always prefer the local edit. Reasonable-sounding and wrong in general: it means a server-side change made by the same user on another device is silently discarded, which is the same bug pointed the other way.
Always prefer the server. The opposite, and the one you get by accident most often, because the sync usually finishes last. It is defensible as a policy and indefensible as an accident, which is exactly the distinction this post is about.
The actual mechanism
The policy exists whether or not anybody chose it. Name it, put it somewhere a reviewer will see it, and make the write path go through it.
write(record, resolver)
resolver: (local, remote) -> record
The resolver is a named thing with a type. That single change moves the policy from an emergent property of execution order into an argument that somebody had to supply — and anything that has to be supplied gets noticed, discussed and reviewed. The previous situation was not an absence of policy; it was a policy that nothing could inspect.

Naming it also makes the per-record question askable. Different records genuinely want different answers — a user's display name should prefer their edit, a server-computed balance should prefer the server, a set of tags might want a union rather than either. With one implicit policy those are all the same; with a named resolver each record type can state its own, and the statement is in the code rather than in somebody's memory.
The sub-case worth designing for is the one the resolver cannot answer: genuine conflict, where both sides changed and neither should win. The honest response is to represent that as an outcome rather than forcing a choice — a resolver that can return "conflicted" lets the application decide whether to prompt, queue, or merge, and a resolver that must return a record forces a silent pick for a case that deserved attention.
There is one more thing the resolver gives you that is easy to miss: it is testable. An implicit policy that emerges from execution order can only be tested by orchestrating a race. A function from two records to one is a pure function, and its behaviour on every interesting pair can be asserted directly, with no timing involved at all.
The fix
resolver a named function from (local, remote) to an outcome
per type each record type declares which resolver it uses
outcome may be local, remote, merged, or conflicted
write path cannot write without one; there is no default parameter
The type is the mechanism: there is no overload that omits the resolver, so "last write wins" has to be typed out by somebody who meant it.
sealed interface Resolution<out T> {
data class UseLocal<T>(val value: T) : Resolution<T>
data class UseRemote<T>(val value: T) : Resolution<T>
data class Merged<T>(val value: T) : Resolution<T>
data class Conflicted<T>(val local: T, val remote: T) : Resolution<T> // surface it
}
fun interface Resolver<T> { fun resolve(local: T, remote: T): Resolution<T> }
// Declared per record type, at the type, where it is read with the model.
val NoteResolver = Resolver<Note> { local, remote ->
when {
local.body == remote.body -> Resolution.UseRemote(remote)
local.updatedAt > remote.updatedAt && !remote.editedSinceSync
-> Resolution.UseLocal(local)
local.editedSinceSync && remote.editedSinceSync
-> Resolution.Conflicted(local, remote) // ask
else -> Resolution.UseRemote(remote)
}
}
class SyncWriter<T>(private val resolver: Resolver<T>) { // no default. none.
fun write(local: T, remote: T): T = when (val r = resolver.resolve(local, remote)) {
is Resolution.UseLocal -> r.value
is Resolution.UseRemote -> r.value
is Resolution.Merged -> r.value
is Resolution.Conflicted -> error("unresolved conflict — surface to the user")
}
}
Two devices editing the same note while both were offline:
local : "…draft intro" updatedAt=10:04 editedSinceSync=true
remote : "…revised intro" updatedAt=10:06 editedSinceSync=true
→ Conflicted(local, remote)
Last-write-wins returns the 10:06 copy and the other edit is gone with nothing recorded. The type
cannot express that silently: Conflicted has to be handled at the call site.
The last line matters more than it looks. A resolver with a default is a resolver nobody chooses, which is where this started — the default becomes the policy for every record type that nobody thought about, and those are precisely the ones where it is most likely to be wrong.
Making it mandatory costs one explicit argument per write path. That is a small, visible tax, and it is paid at exactly the moment somebody is in a position to think about the answer.
There is a question of where the resolver is chosen, and the answer that works is at the record type rather than at the call site. A resolver passed per call means two writes to the same record can use different policies, which is the original problem with an extra step — now the outcome depends on which code path reached the write first. Declared once per type, the policy is a property of the data rather than of whoever happened to be writing.
That also makes it reviewable in the right place. A change to how conflicts resolve for a record type is a single line in one place, visible as a deliberate change, rather than a parameter somebody altered in a function they were editing for a different reason.
How we proved it
The policy's existence is demonstrable even when it is implicit, and that is the observation worth taking away. Run the same two writes in the other order and the record ends up different. Something decided; it just was not code anybody wrote. Once you can show that, the argument for naming it does not need much more.
The testability claim is concrete. A resolver is a pure function, so its behaviour is asserted with ordinary unit tests over pairs of records — including the cases that are nearly impossible to produce by racing two real writes. That is a genuine change in what can be verified, not just in what can be read.
What I cannot tell you is how often conflicts actually occur. I have no counter on conflicted outcomes, because for most of this system's life there was no code path that could recognise one. The honest position is that I made the policy explicit because an unexamined one was clearly wrong, not because I measured the rate of silent overwrites — and I still cannot measure it retrospectively.
The timestamp approach deserves a fairer hearing than I gave it in the failed-fixes section, because it is not wrong so much as incomplete. Comparing times works when both sides are stamped by the same clock — a server that stamps on receipt, say — and the failure I described is specifically about comparing a device clock to a server clock. A resolver can perfectly well implement a timestamp policy; the point is that it is then a named policy with a stated assumption about whose clock, rather than an implicit rule.
That is the distinction worth keeping. None of the individual policies in this post is bad. What was bad was having one by accident, and the resolver is just the place where whichever you pick becomes something you picked.
What I have not solved: the conflicted outcome in the interface. The resolver can report that both sides changed; what the application should do about it is a design question I have answered by preferring the local edit and logging, which is a policy wearing a different hat. A real answer involves showing the user both values, and I have not built that.

And a limitation of the whole framing: a resolver sees two records and not their histories. It cannot distinguish "the remote changed" from "the remote is an older copy that was never updated", because both present as a value that differs from the local one. Doing better means carrying version information per record, which is a larger mechanism, and without it the resolver is making a well-named decision on incomplete information.
One more thing I would do differently: I built the resolver before I built any way to observe it. Conflicts are now resolved by a named policy and nothing counts how often each branch is taken — so I still cannot answer the question from two paragraphs ago. A counter per outcome would have cost almost nothing at the time and would have told me, by now, whether conflicts are rare enough that the policy barely matters or common enough that the choice is load-bearing.
What this generalizes to
Whenever two operations can affect the same state and nothing arbitrates, there is still a policy — it is just written in the scheduler rather than in your code.
That is the uncomfortable part. The absence of a decision is not neutrality; it is a decision made by timing, which is the one input you have least control over and the one that varies most between a developer's machine and a user's. And because it is never written down, it cannot be reviewed, cannot be tested, and changes whenever anything about latency changes.
So when you find an outcome that depends on which of two things finished first, the fix is rarely to make the ordering deterministic. It is to find the decision that was being made implicitly, give it a name and a type, and require somebody to supply it — at which point it becomes a thing that can be argued about, which is all you wanted in the first place.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".