Editing the same file on two computers

Here’s a problem that sounds simple until you actually sit down and try to solve it.

You’re building a local-first app. Let’s call it Thangs, a Things 3 clone for people who like their to-do lists fast, offline, and free of subscription fatigue. The whole pitch is that your data lives in a file on your machine, and if you want sync, you just drop that file in Dropbox or iCloud and let the cloud do its dumb cloud thing.

Then one day you open Thangs on your laptop, and it’s also open on your desktop at home, and both apps have unsaved changes sitting in memory. Laptop saves. Desktop saves two seconds later. Desktop wins, laptop’s work is gone, and somewhere a user is quietly composing a one-star review.

That’s the game. Two replicas of the same file, edited independently, and no referee in the middle. How do you make them agree without anyone losing work?

The naive fix, and why it rots

Your first instinct is probably a three-way merge. It’s the classic move. You compare three things:

  1. The file as it currently sits on disk
  2. The version your app is holding in memory
  3. The original version you loaded when the app started

Then you write logic that figures out who changed what and stitches it together. And honestly, for a flat little file, it kind of works.

But Thangs isn’t flat. You’ve got areas, projects inside areas, tasks inside projects, fields inside tasks. The hierarchy gets deep, the edge cases start stacking up like dirty dishes, and every bug fix spawns two new bugs. What looked like a weekend of clever diffing turns into a swamp of special cases that nobody on the team fully understands anymore. Been there. It’s not a good place.

Enter CRDTs

The industry-standard answer to this mess is a family of data models called Conflict-free Replicated Data Types, or CRDTs. Strip away the academic packaging and the promise is two things:

  1. Any replica can be edited independently. No phoning home, no lock server, no “please wait while we check with the mothership.”
  2. Once two replicas have seen the same updates, they deterministically converge to the exact same state. Not “usually the same.” The same. Guaranteed by mathematically sound merge rules.

That second point is the magic. It means you can build fully decentralized software where the sync layer is as dumb as a bag of hammers. Dropbox doesn’t need to understand your file. It just needs to move bytes. All the intelligence lives in the app, and every copy of the app, given the same information, reaches the same conclusion.

But first, you need to agree on time

The specific flavor of CRDT we’re talking about here is Last Writer Wins. When two devices both change a task’s title, the most recent change wins. Simple rule, easy to reason about.

Except now everything hinges on one question: what does “most recent” actually mean?

Physical clocks will betray you

Your laptop’s clock and your desktop’s clock are not the same clock. They drift. They disagree by seconds, sometimes minutes. And even if they were perfectly synced, two edits can land in the exact same millisecond and now you’ve got a tie with no tiebreaker.

Trusting raw wall-clock time across distributed devices is like trusting the “fresh” sticker on gas station sushi. Technically it’s information. You should not build your life on it.

Hybrid logical clocks

The fix is a Hybrid Logical Clock, or HLC. It’s a timestamp that combines physical time with a logical counter, and it’s one of those ideas that feels obvious about five minutes after someone explains it to you.

Here’s the trick. Say your desktop’s clock is running ten minutes fast. It makes a change and stamps it with that future time. Your laptop receives the change and thinks, huh, this edit is from the future according to my own clock. Instead of panicking, the laptop adopts that future timestamp as its new baseline and bumps the logical counter for its own next change.

Roughly:

on local change:
    now = physical_time()
    if now > last_seen_time:
        last_seen_time = now
        counter = 0
    else:
        counter += 1
    timestamp = (last_seen_time, counter, device_id)

The result is that causality survives even when the wall clocks are lying. If my edit happened after I saw yours, my timestamp is guaranteed to sort after yours, ten-minute clock skew be damned.

The final tiebreaker

And if two changes somehow have identical physical time and identical counters? Compare device IDs. It’s completely arbitrary, and that’s fine. The point was never fairness. The point is that every device runs the same comparison and lands on the same winner, every single time. Deterministic beats correct-feeling.

Modeling the data

In Thangs, a file holds projects, and projects hold tasks. The CRDT state gets stored right inside the file as JSON, and here’s the part that matters: changes are tracked per field, not per object.

So a task isn’t one blob that gets overwritten wholesale. Its title and its notes and its due date are each their own little entry with their own timestamp:

{
  "projects": {
    "proj_groceries": {
      "tasks": {
        "task_9f2a": {
          "title":   { "value": "Buy olive oil", "ts": "2026-08-30T14:02:11.000Z-0003-deviceA" },
          "notes":   { "value": "The good stuff, not the blend", "ts": "2026-08-30T14:02:15.000Z-0000-deviceA" },
          "dueDate": { "value": "2026-09-01", "ts": "2026-08-31T09:15:40.000Z-0001-deviceB" }
        }
      }
    }
  }
}

Notice what’s stored. Not a history. Not an endless append-only log of everything that ever happened. Just the last applied change per field and its timestamp. That’s a deliberate trade. You lose the full audit trail, but the file stays lean forever.

The merge itself is almost boring

And this is where it all pays off. Say your laptop updates a task’s title and due date. Later, your desktop updates just the due date. Both files bounce through a completely oblivious cloud drive.

When either app sees the other’s version, the merge logic is embarrassingly simple. Walk the fields. For each one, compare timestamps. Keep the newer value. Done.

for each field in incoming_file:
    if incoming.ts > local.ts:
        local.value = incoming.value
        local.ts = incoming.ts

No merge dialogs. No “conflicted copy” files breeding in your Dropbox like rabbits. Both machines look at the same evidence, apply the same rules, and arrive at the same file. Every time.

Deletions: where things get a little grim

Deletes need special treatment, because here’s a fun failure mode: User A deletes a project on one device while User B is renaming a task inside that same project on another. If you’re not careful, B’s innocent little rename resurrects the whole project from the dead. Zombie data. Nobody wants zombie data.

The rule here is delete always wins, and the mechanism is a tombstone. When something dies, you don’t just erase it. You record its ID and time of death in a separate hashmap:

{
  "tombstones": {
    "proj_groceries": { "deletedAt": "2026-08-31T10:00:00.000Z-0000-deviceA" }
  }
}

Now when B’s rename comes through, the merge checks the graveyard first, sees the project is deceased, and lets it rest. Deletion overrides updates, full stop. It’s a blunt rule, but blunt rules that always give the same answer are exactly what you want in a system with no referee.

What it costs you

Two honest caveats, because everything costs something.

File size, first. Yes, you’re carrying timestamps for every field plus a tombstone graveyard. In practice it’s nothing. A Thangs file with years of projects and tasks will struggle to ever hit a single megabyte. You’re paying pennies for determinism.

Second, and this one matters: a CRDT guarantees mathematical convergence, not semantic correctness. If you typed a due date of the 5th on one machine and the 8th on the other, the system has no idea which one you actually meant. It doesn’t know. It can’t know. All it promises is that every device will look at the same timeline, apply the same rules, and agree on the same final answer.

Agreement, not truth. For syncing a to-do list across your devices, agreement is exactly enough. Know what your tools promise and don’t ask them for more than that.

Further reading

If you want the real thing straight from the source, these two papers are where to go:

  1. Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases Sandeep Kulkarni, Murat Demirbas, Deepak Madeppa, Bharadwaj Avva, and Marcelo Leone https://cse.buffalo.edu/tech-reports/2014-04.pdf

  2. Conflict-free Replicated Data Types: An Overview Nuno Preguiça https://arxiv.org/pdf/1806.10254

The first one is the HLC paper. The second is the best broad map of the CRDT landscape you’ll find in one PDF. Both are more readable than academic papers have any right to be.