Web Development · Architecture
Local-First Software in 2026: CRDTs, Sync Engines, and When the Complexity Is Worth It
Local-first means your app works offline and syncs when connected. The technology (CRDTs and sync engines) is mature enough to use. The question is whether your use case actually needs it.
Anurag Verma
9 min read
Sponsored
Linear loads instantly. You open it, your issues are there, you can create and edit them without waiting for network round-trips. Figma lets two designers edit the same frame simultaneously without either of them seeing a conflict modal. Notion works offline and syncs cleanly when you reconnect.
These products share an architectural pattern: they treat the local device as the primary source of truth and sync with a server as a secondary concern. This is local-first design, and it’s distinct from the conventional approach where the server holds the truth and the client just displays it.
The difference matters for user experience. Traditional client-server apps feel slow because every action waits for a network round-trip. Local-first apps feel fast because actions update the local state immediately, then sync in the background.
The technology that makes this work without data corruption is called CRDTs (Conflict-free Replicated Data Types). This is the part that looks scary but is actually usable today.
What a CRDT Actually Is
A CRDT is a data structure designed so that multiple copies of it can be modified independently and merged without conflicts, regardless of the order the changes arrived.
The simplest example is a set where you can only add items (a Grow-only Set or G-Set). If two clients both add items to the same set while offline, merging is trivial: the result is the union of both sets. No conflicts possible.
Real applications need more than sets. Text editing is the hard case. When two users edit the same document simultaneously. If one inserts “fast” at position 5 and the other deletes characters 3-7, naive merging produces garbage.
Text CRDTs solve this by tracking every character’s identity and position relative to other characters rather than using a numeric index. The two most widely used text CRDT algorithms are:
- YATA (used by Y.js)
- RGA (Replicated Growable Array, used by Automerge and others)
Both produce the same result for concurrent edits and handle the full set of text operations: insert, delete, format. The implementations are well-tested. You don’t need to understand the algorithm to use the library.
The Two Main Libraries
Y.js is the most widely adopted CRDT library in the JavaScript ecosystem. It supports:
- Text (with rich text extensions for Quill, ProseMirror, CodeMirror, and Monaco)
- Arrays
- Maps
- XML documents
Y.js has a provider model for persistence and networking. You choose a provider (or build one) that handles how changes get stored and synced. The library itself handles conflict resolution.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
// Create a Y.js document
const ydoc = new Y.Doc();
// Connect to a sync server
const provider = new WebsocketProvider('wss://your-sync-server.com', 'room-name', ydoc);
// Get a shared text type
const ytext = ydoc.getText('quill-content');
// Bind it to a Quill editor instance
const binding = new QuillBinding(ytext, quillEditor, provider.awareness);
// Changes to the editor are automatically synced to all connected clients
// Changes from other clients appear in the editor without conflicts
The WebsocketProvider handles online sync. For offline support, add y-indexeddb which persists the document to IndexedDB in the browser:
import { IndexeddbPersistence } from 'y-indexeddb';
// Persist locally
const persistence = new IndexeddbPersistence('document-id', ydoc);
persistence.once('synced', () => {
console.log('Loaded from IndexedDB');
});
With both providers active, the document loads from IndexedDB immediately (fast), then syncs any changes from the server when the WebSocket connects.
Automerge takes a different approach. Rather than a provider model, Automerge represents the entire document as an immutable value with a change log. You apply changes and produce new document states:
import * as Automerge from '@automerge/automerge';
// Initialize
let doc = Automerge.init();
// Make changes (always via a change function)
doc = Automerge.change(doc, 'Set title', d => {
d.title = 'My Document';
d.items = [];
});
// Another change
doc = Automerge.change(doc, 'Add item', d => {
d.items.push({ text: 'First item', done: false });
});
// Serialize to Uint8Array for storage or transmission
const binary = Automerge.save(doc);
// Load from binary
const loaded = Automerge.load(binary);
// Merge with changes from another peer
const merged = Automerge.merge(doc, remoteDoc);
Automerge 2.0 rewrote the core in Rust and compiled to WebAssembly, which made it roughly 10x faster than the original JavaScript implementation. For large documents or high-frequency edits, this matters.
Sync Engines: Handling the Server Side
Y.js and Automerge solve the conflict resolution problem. The sync layer (getting changes between clients and storing them reliably) is a separate concern.
For Y.js, the y-websocket server is the standard starting point. It’s a Node.js server that rooms clients together and broadcasts changes. It does not persist the document by default; for persistence you add a storage provider.
# Run a local y-websocket server
npx y-websocket-server
For production, you want persistence. The y-leveldb provider persists to LevelDB on the server:
// server.js
import { WebsocketServer } from 'y-websocket/bin/utils.js';
import { LeveldbPersistence } from 'y-leveldb';
const persistence = new LeveldbPersistence('./db');
const wss = new WebsocketServer({ port: 1234, persistence });
Electric SQL is a newer option for teams already using PostgreSQL. It syncs Postgres data to SQLite in the browser (via WebAssembly) and handles the sync protocol. The model is closer to replication than CRDTs. You write to Postgres, Electric syncs the relevant rows to each client’s local SQLite, and writes from the client sync back.
import { ElectricDatabase, electrify } from 'electric-sql/browser';
import { schema } from './generated/client';
const conn = await ElectricDatabase.init('myapp.db');
const electric = await electrify(conn, schema);
// Sync a table subset to the local database
const { db } = electric;
await db.items.sync({ where: { user_id: currentUserId } });
// Query locally (fast, no network)
const items = await db.items.findMany({
where: { done: false },
orderBy: { created_at: 'desc' }
});
Electric’s approach works well if your data is relational and you’re already on Postgres. Y.js works better for unstructured collaborative data like documents and canvas elements.
When Local-First Is Worth It
Local-first architecture adds real complexity. You need to:
- Handle sync state (loading, syncing, error) in your UI
- Manage storage on the client (IndexedDB has limits and quirks)
- Think about conflict resolution for every data type in your app
- Operate sync infrastructure alongside your regular backend
- Handle schema migrations differently. You can’t just run a migration that changes the format of data in millions of offline caches
This complexity pays off in specific cases:
Collaborative real-time editing. If two people can edit the same thing at the same time, CRDTs are the cleanest approach. Operational transform (the older alternative) is harder to implement correctly and has more edge cases.
Offline-first field tools. Apps used in places without reliable internet (field service, agriculture, clinics in areas with poor connectivity), need to work offline and sync when connected. Local-first is the right architecture for this, not just an optimization.
High-frequency UI updates. If the user is doing something that generates many changes per second (drawing, dragging, live editing), the latency of waiting for server acknowledgment between updates creates a bad experience. Local state updated immediately with background sync removes the bottleneck.
Single-user apps with cross-device sync. Notes apps, todo lists, personal journals. The user works offline, changes sync across their devices without conflicts. This is simpler than the multi-user case because you only need to handle edits from one logical author across multiple sessions.
Local-first is probably not worth it for:
- Apps where concurrent editing doesn’t happen (you work, then another person reviews, no overlap)
- Simple CRUD applications where optimistic updates with rollback on error are good enough
- Data that must be validated server-side before being accepted (financial transactions, medical records with approval workflows)
Optimistic Updates as the Middle Ground
Most apps don’t need full CRDT-based sync. What they need is to feel fast. Optimistic updates give you most of the perceived performance benefit with much less infrastructure.
The pattern: update local state immediately when the user takes an action, show the result, then sync to the server in the background. If the server rejects the update, roll back and show an error.
// React example with TanStack Query
const mutation = useMutation({
mutationFn: (newItem: Item) => api.createItem(newItem),
onMutate: async (newItem) => {
// Cancel in-flight queries for this data
await queryClient.cancelQueries({ queryKey: ['items'] });
// Snapshot current state for rollback
const previousItems = queryClient.getQueryData(['items']);
// Optimistically add the new item
queryClient.setQueryData(['items'], (old: Item[]) => [...old, newItem]);
return { previousItems };
},
onError: (err, newItem, context) => {
// Roll back to snapshot on error
queryClient.setQueryData(['items'], context?.previousItems);
},
onSettled: () => {
// Refetch to ensure we're in sync with the server
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});
This works well when conflicts are rare (most apps) and server validation is required (most apps). The user gets instant feedback, and the edge case of a failed sync is handled gracefully.
Reach for CRDTs when conflicts aren’t rare. When the same data genuinely gets edited by multiple people or devices simultaneously and you need those edits to merge correctly.
The Practical Starting Point
If you want to try local-first without building everything from scratch, start with Y.js and the hosted Liveblocks or PartyKit service instead of running your own sync server. Both handle the server-side infrastructure and let you focus on the client-side integration.
npm install yjs @liveblocks/client @liveblocks/yjs
import { createClient } from '@liveblocks/client';
import LiveblocksProvider from '@liveblocks/yjs';
import * as Y from 'yjs';
const client = createClient({ publicApiKey: 'pk_your_key' });
const room = client.enter('room-id', { initialPresence: {} });
const ydoc = new Y.Doc();
const provider = new LiveblocksProvider(room, ydoc);
// Now you have a synced Y.js document with persistence handled by Liveblocks
const sharedMap = ydoc.getMap('app-state');
sharedMap.set('count', 0);
The architecture is sound and the libraries are stable. The question for each project is whether the use case justifies the additional complexity. For collaborative editing or genuinely offline-capable tools, the answer is yes. For everything else, good optimistic update patterns get you most of the way there with a fraction of the infrastructure.
Sponsored
More from this category
More from Web Development
NestJS in 2026: The Enterprise Node.js Framework Most Teams Overlook
Test-Driven Development With AI Coding Assistants: Does TDD Still Make Sense in 2026?
WebGPU in 2026: What You Can Actually Build With GPU Compute in the Browser
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored