...
Back

Local-First Is a Sync Problem Wearing a Storage Costume

Putting the data on the user's machine is the easy half. Everything that makes local-first hard shows up the moment a second device exists — and Git-native tools inherit both the good and the bad of that choice.

Local-First Is a Sync Problem Wearing a Storage Costume

Local-First Is a Sync Problem Wearing a Storage Costume 🗃️

A local-first, Git-native issue tracker made the rounds on Lobsters this month, and the discussion followed the shape these discussions usually take: enthusiasm for owning your data, then a long tail of comments about merge conflicts.

That tail is the actual subject. Local-first is rarely hard because of where the bytes sit. It is hard because "local" and "first" are a promise about availability, and availability across more than one machine is a distributed systems problem you have opted into.


What the label actually commits you to

The useful definition is not "the data is on disk." It is roughly:

  1. The application works with no network, indefinitely, including writes.
  2. Changes made offline are not lost and not second-class.
  3. Multiple devices converge on the same state without a server arbitrating.
  4. The user can take the data elsewhere.

Points 1 and 4 are storage decisions and they are genuinely straightforward. Points 2 and 3 are the whole engineering problem, and they do not get easier by choosing a file format.

The moment two devices can both write while partitioned, you need an answer to concurrent edits. There are only a few honest answers: a conflict-free data type that merges automatically, an explicit merge UI that asks the human, or last-writer-wins with a clock — which is not a merge strategy but a data-loss strategy with good manners.


What Git gets you, precisely

Using Git as the substrate is a real and underrated choice, and it is worth naming what it actually provides:

  • A content-addressed store with history. You get durability, diffing and an audit trail without building any of it.
  • A transport everyone already has. SSH, HTTPS, a USB stick. No sync service to run, no account to create, no bill.
  • A permission model that already exists. Whoever can push can write. That is coarse, but it is understood and it is administered somewhere else.
  • Review as a first-class operation. Changes can be proposed, discussed and merged by people, which is exactly right for some data.

That is a lot to inherit for free, and for data that is already text that people already review, it is close to ideal.


And what it costs

Git's merge is line-based and it does not know what your data means. That is fine for prose and source code, where a human reading a conflict can resolve it. It is poor for structured records, where the conflict is semantic and the line-level view actively obscures it.

Two people move the same issue to different columns. Line-wise that is one changed line in one file, and Git will present it as a trivial conflict. But the correct resolution is not "pick a line" — it depends on what the columns mean, whether the transitions were both valid, and whether one of them triggered something else. The format hid the problem rather than surfacing it.

Three practical consequences:

  • One record per file, always. Any file that aggregates records turns unrelated concurrent edits into conflicts. The granularity of your files is your concurrency granularity, and it is a decision you make once and live with.
  • Append beats mutate. A log of events that merges by union is far more robust than a mutable document, because union has no conflicts. You pay in read complexity and compaction.
  • Field-level ordering matters. Serialise deterministically — stable key order, stable formatting — or you will generate conflicts that represent no disagreement at all. Two clients writing the same logical state must produce byte-identical output.

When the trade is right

Git-native is a good fit when the data is low-write, human-reviewed, and naturally partitioned by record: issues, documentation, configuration, decisions. The write rate is low enough that true concurrency is rare, and when it happens a human is the right resolver anyway.

It is a bad fit when writes are frequent, machine-generated, or ordered: chat, telemetry, anything with a real-time collaborative cursor, anything where "who wrote last" carries meaning. For those, a CRDT is not an optimisation, it is the requirement, and you should reach for one instead of teaching Git to be one.


The part worth stealing regardless

Even if you never use Git as a store, two of its properties are worth copying into whatever you build:

The data should be legible without your application. If the user can read, diff and grep their own records with ordinary tools, then "take the data elsewhere" is a fact rather than an export button you have to maintain. This is the single most credible form of the promise that the data is theirs.

History should be cheap and complete. Not "we keep the last 30 days in a table" but "every state this record has been in is recoverable." Local-first systems get asked to reconcile divergent history constantly, and a system that discards history cannot reconcile anything — it can only overwrite.

Those two are independent of transport, format and merge strategy. They are also what people actually mean when they say they want to own their data.