Building software that keeps working on a plane, in a basement, or on hotel wifi that drops every ninety seconds means giving up the idea that the server holds the truth. Once a device can write while disconnected, two devices can write conflicting things, and somebody has to decide what the data looks like afterward.
The category built around that problem moved in 2026 in ways most explainers have not picked up. ElectricSQL dropped the SQL and became Electric, and now pitches itself as "the first agent platform built on sync," aiming at AI agent state rather than offline web apps specifically. PowerSync said less and shipped more, and its site now names 7-Eleven and Stanley Black & Decker as production users. The technology underneath did not change, but "sync engine" has clearly moved from conference-talk material to things companies run.
What follows is the mechanical version: code you can execute, output I actually got, and the one behavior that surprises people. The longer treatment, including the full decision framework, is on DevToolLab.
Where the Term Comes From
"Local-first" was named in a 2019 Ink & Switch paper by Martin Kleppmann, Adam Wiggins, Peter van Hardenberg and Mark McGranaghan, presented at ACM's Onward! conference. The proposition is that an application should read and write local storage first, so it is instant and fully usable offline, then reconcile with other devices in the background without any server acting as the authority or the single point of failure.
Removing that authority is where the difficulty concentrates. Two people, or one person on a laptop and a phone, both edit the same record while disconnected. On reconnect, something has to produce one answer. A CRDT, short for Conflict-free Replicated Data Type, is a structure built so independent copies can always be merged into an identical result on every replica, deterministically, with nobody arbitrating and no conflict dialog shown to a user.
Two Different Tools for Two Different Problems
| Approach | What you get | Reach for it when |
|---|---|---|
| CRDT library (Yjs, Automerge) | A data structure embedded in your app; storage and transport are yours to wire up | Collaborative editing, rich text, whiteboards, anything with fine-grained concurrent edits |
| Sync engine (Electric, PowerSync, Zero) | A backend service plus client SDK replicating a real database, usually Postgres, into local SQLite or a reactive store | You already run a Postgres app and want offline reads and writes without redesigning the data layer |
Start with the library side, because it is the part you can verify yourself in about two minutes.
Watching a Merge Happen
Yjs is the CRDT implementation you will find in the most production JavaScript. It backs collaborative editing in Jupyter and anything built on Tiptap or ProseMirror.
npm install yjs
The setup: two laptops begin with the same empty shared list, then both go offline and add different entries.
// demo.mjs
import * as Y from 'yjs'
const laptopA = new Y.Doc()
const laptopB = new Y.Doc()
const todosA = laptopA.getMap('todos')
const todosB = laptopB.getMap('todos')
todosA.set('buy-milk', { done: false })
todosA.set('walk-dog', { done: true })
todosB.set('buy-milk', { done: false })
todosB.set('call-dentist', { done: false })
console.log('Laptop A before merge:', todosA.toJSON())
console.log('Laptop B before merge:', todosB.toJSON())
// This is what a sync provider does over the network: exchange
// each replica's update and apply it to the other.
Y.applyUpdate(laptopB, Y.encodeStateAsUpdate(laptopA))
Y.applyUpdate(laptopA, Y.encodeStateAsUpdate(laptopB))
console.log('Laptop A after merge:', todosA.toJSON())
console.log('Laptop B after merge:', todosB.toJSON())
On yjs 13.6.31, node demo.mjs prints:
Laptop A before merge: { 'buy-milk': { done: false }, 'walk-dog': { done: true } }
Laptop B before merge: { 'buy-milk': { done: false }, 'call-dentist': { done: false } }
Laptop A after merge: {
'buy-milk': { done: false },
'walk-dog': { done: true },
'call-dentist': { done: false }
}
Laptop B after merge: {
'buy-milk': { done: false },
'call-dentist': { done: false },
'walk-dog': { done: true }
}
Three items on both sides, zero lines of merge logic. There is a trap hiding in that output though: the keys print in a different order on A than on B, because a Y.Map iterates in the order its own replica happened to merge things, not in a canonical one. Compare two replicas by running JSON.stringify() on both and you will get mismatches that are not real. Sort the entries first, or compare values rather than serialized strings.
The Case That Actually Teaches You Something
Merging separate keys is the easy half. The question worth asking is what happens when both replicas write the same key offline.
// conflict.mjs
todosA.set('buy-milk', { done: true, note: 'got it at the corner store' })
todosB.set('buy-milk', { done: true, note: 'oat milk this time' })
Y.applyUpdate(laptopB, Y.encodeStateAsUpdate(laptopA))
Y.applyUpdate(laptopA, Y.encodeStateAsUpdate(laptopB))
console.log('A after merge:', todosA.get('buy-milk'))
console.log('B after merge:', todosB.get('buy-milk'))
One note survives and the other disappears without a trace. You do not get to pick which. Yjs settles concurrent writes to a key using each replica's internal client ID, and new Y.Doc() hands those out at random. I ran it twenty times: sometimes "corner store" took it, sometimes "oat milk", with no pattern. The invariant across all twenty runs was that A and B never disagreed with each other.
run 1: A= oat milk this time B= oat milk this time
run 2: A= got it at the corner store B= got it at the corner store
run 3: A= oat milk this time B= oat milk this time
That is the whole guarantee, stated precisely: every replica converges on one identical answer. Not a clever answer, not a predictable one. The cost is real and worth stating plainly, because a losing edit vanishes with no warning and nothing for a human to review. Acceptable for a grocery list. Not acceptable for two lawyers in the same paragraph, where you want granularity below the whole object: separate properties per field, or Yjs's text type, which merges insertions character by character instead of replacing a JSON blob wholesale.
Persistence and an Actual Network
Everything above lives in memory and dies on refresh. Production needs local storage plus a transport, and Yjs keeps both out of the core package.
npm install yjs y-indexeddb y-webrtc
import * as Y from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
import { WebrtcProvider } from 'y-webrtc'
const ydoc = new Y.Doc()
// Persists to the browser's IndexedDB, survives refresh and offline restarts
const persistence = new IndexeddbPersistence('todo-list', ydoc)
// Syncs peer-to-peer between browser tabs/devices in the same "room"
const provider = new WebrtcProvider('todo-list-room', ydoc)
const todos = ydoc.getMap('todos')
todos.observe(() => {
console.log('todos changed:', todos.toJSON())
})
That is multi-device offline-capable sync with no backend of your own. Be aware y-webrtc leans on public signaling servers out of the box, so run your own before shipping. Prefer a server you control over peer-to-peer? Replace WebrtcProvider with y-websocket's WebsocketProvider aimed at your endpoint.
Two DevToolLab tools earn their keep while debugging this: JSON Diff for checking whether two replica dumps really converged after a merge, and the Diff Checker for seeing exactly which characters moved in a text CRDT field across a sync.
When a Sync Engine Is the Better Answer
If Postgres already backs your app and the goal is offline reads and writes rather than a rearchitecture around documents, the database-shaped products solve a different half of the problem.
| Sync engine | Syncs | License / pricing |
|---|---|---|
| Electric (formerly ElectricSQL) | Postgres to clients over HTTP/JSON, using Postgres logical replication | Apache 2.0, open protocol |
| PowerSync | Postgres, MongoDB, MySQL, or SQL Server to client-side SQLite | Source-available self-hosted, or hosted with a free tier |
| Zero (Rocicorp) | Postgres to a reactive client-side store via a custom query engine (ZQL) | Open-source and self-hostable, or hosted from $30/month |
The commitment is heavier than adding a library, since their sync protocol becomes your data layer. What you get back is offline behavior against the schema you already have, with no new document model to design. Electric's turn toward agents is the one to watch if you are building anything where an agent needs live application state rather than a single API response.
How to Decide
Pick Yjs or Automerge when the product is collaborative editing, whiteboarding, or any interface where several people touch the same content at fine granularity.
Pick Electric, PowerSync or Zero when a Postgres app already exists and offline support should not require rethinking how the data is modeled.
Pick neither when your app is mostly reads, rarely used disconnected, and a plain REST API with optimistic updates already covers it. A merge model you do not need is pure cost. The full article walks through each of these with more detail on the tradeoffs.
Conclusion
The job a CRDT does is narrower than the marketing around local-first suggests: hand it two diverged copies, get back one merged result, with a guarantee that every other replica computes the identical result without coordination. That is it, and the snippets above are the entire mechanism rather than an illustration of it.
The harder question is whether you have that problem. If you do, run the two-laptop test before committing to anything, then work out whether you need a document model like Yjs's or a sync engine parked in front of a database you already operate.
Top comments (0)