Engineering
Local-first by default
Why we put the database on every device — and what changes when the cloud is no longer the source of truth.
For most of the last fifteen years, “where does the data live?” has had the same answer: in a database, in a region, behind an API. Your laptop is a thin client. Your phone is a thinner client. The application you think you are using is, in reality, a small UI that ships every interesting question off to a server, waits, and renders the answer.
That model was a good fit when networks were the bottleneck and storage was scarce. It is not a good fit anymore. A modern phone has more storage than a 2010 server, a CPU that can run a vector index in its sleep, and a battery that prefers radios to be off. Local-first asks an obvious question once you state the trade-offs that way: what if the database lived on the device, and the cloud was just one peer among many?
New Journey is built that way from the floor up.
What “local-first” means here, concretely
Local-first is not just “we cache some data offline.” It is a load-bearing architectural commitment with three properties:
- Every read is a local read. Opening an issue, searching across a workspace, listing your assigned work — none of these touch the network. They hit a database file that lives in your application’s sandbox.
- Every write is a local write. When you type a comment, the comment is durable on your device before any server sees it. The server is a syncing peer, not a gatekeeper.
- Conflicts converge automatically. Two people editing the same field offline do not need a merge conflict UI. They need a CRDT that produces the same result regardless of the order writes arrive in.
The first two are tractable with any embedded database. The third is the hard one, and it is the reason most “offline-capable” apps you have used quietly fall over the moment two devices touch the same row at the same time.
What changes when queries become microseconds
The most underrated consequence of local-first is the way it changes what is cheap. A query that takes 80ms over a fast network might take 80μs against a local LSM index. That is not a 1000× speedup in a graph; it is a qualitative change in what the UI is allowed to do.
You can rebuild the entire sidebar on every keystroke. You can recompute “what is blocked by what” continuously. You can run a search-as-you-type that is actually as-you-type, not as-you-pause-for-300ms-and-hope. The product feels different because it is different — there is no async boundary between intent and answer.
When we measured getIssue on the planning slice, the median was 120μs cold and 12μs warm. Across the wire, the same call had a P50 of 47ms and a long-tailed P99 over 400ms. The server is not slow; the network is. Local-first removes the network from the inner loop of the application.
What it costs
Local-first is not free, and we want to be honest about the bill.
- More bytes on device. Every workspace member is, in effect, a partial replica. We compress aggressively, paginate cold history, and let users opt into “summary-only” sync for very large communities, but the floor is “more than zero.”
- Sync complexity. When the source of truth is everyone, the system has to be very careful about identity, ordering, and authorisation. Our sync engine is a non-trivial chunk of code, and it is the part we treat with the most paranoia.
- Different mental model for backend engineers. A server that mostly forwards CRDT updates is not the kind of backend most people are used to writing. The “business logic” lives next to the user, not next to the database. This is a feature, but it requires unlearning some habits.
The CRDT story is what makes the bill payable. We use Loro for issue bodies and structured documents — it gives us rich-text and tree-shaped CRDTs with a small enough wire format that mobile sync stays reasonable. Where we need wire compatibility between Dart and Elixir, we use yrs ports (y_dart on the client, y_ex on the server). The combination lets us ship CRDT-backed collaboration without inventing the merge logic ourselves.
What users actually notice
The technical case for local-first is interesting; the user-facing case is more interesting. People do not say “wow, your CRDT is well-designed.” They say:
- The app opens instantly. There is no loading spinner because there is nothing to load — the data is already there.
- Search is fast in a way that other tools are not. (We will get into the LSM-on-mobile post for why.)
- Typing on a plane or a train works. Coming back online, things merge. There is no “you have unsaved changes that conflict with the server” dialog, because there is no server in the loop the way there used to be.
- The app does not get materially slower as the workspace grows. A 10,000-issue workspace and a 100-issue workspace feel the same on the inside.
That last point is the one that surprises people. Cloud-first apps have a subtle property: the bigger your data, the worse they feel, because the network round-trip cost dominates. Local-first apps have the opposite property: the bigger your data, the more value the local index provides relative to a cloud query. Scale becomes a feature, not a tax.
Where we are heading
Local-first is the foundation, not the destination. On top of it sits the rest of New Journey: the LSM search index, the OpenMLS-encrypted chat, the planning CRDTs, the analytics views. Every one of those is easier — sometimes only possible — because the data is already on the device.
We will write more about each of those layers in the coming weeks. For now: if you have ever waited for an app to show you something it already had, you have felt the cost of cloud-first defaults. We are building New Journey on the assumption that the next decade of software will not make you wait for things it already has.