01 · Foundation
Local-First Software
Local-first flips the default architecture of apps: the copy of the data on your device is the primary one, and the server is just another peer that helps devices sync. The app reads and writes a local store — instantly, offline, without a spinner — and a sync engine reconciles changes in the background.
The payoff is a different feel entirely. Every interaction is microseconds instead of a network round trip, the app keeps working on a plane or in a tunnel, and collaboration falls out of the same machinery that handles offline edits.
↗ Origin
The term comes from Ink & Switch's 2019 essay "Local-first software: You own your data, in spite of the cloud." By 2026 it has gone from research agenda to shipping products — Linear, Figma, Superhuman, and Obsidian all run local-first or local-first-adjacent architectures.
The Tell: Where Do Reads Come From?
You can spot the architecture in one line of code. Cloud-first apps await the network on the read path; local-first apps query a store that is already on the device.
read-path.ts
// Cloud-first: every read is a network round trip
const todos = await fetch('/api/todos').then((r) => r.json());
// 50-500ms, fails offline, needs loading + error states everywhere
// Local-first: reads are synchronous, from the device
const todos = db.query('select * from todos where done = 0');
// microseconds, works offline, always renders
// The network still exists - it just moved off the critical path:
sync.subscribe('todos', (change) => db.apply(change));
Mental model
Treat the server like you treat a CDN: an availability and distribution layer, not the source of truth for interactive state. The source of truth is the merged history of everyone's edits.
01 · Foundation
The Seven Ideals
Ink & Switch defined seven ideals that a local-first app should satisfy. They double as an evaluation rubric: most stacks hit three or four, and the trade-offs live in the gaps.
⚡
1 · No spinners
Work happens at the speed of the local device. Latency is a rendering problem, not a data problem.
💻
2 · Multi-device
Your data follows you across laptop, phone, and tablet — sync is built in, not bolted on.
✈️
3 · Network optional
Offline is not an error state. The app is fully functional with zero connectivity.
👥
4 · Collaboration
Real-time multiplayer with other people, using the same merge machinery that handles your own offline edits.
🏛️
5 · Longevity
Data outlives the app and the company. Local files in open formats keep working after the servers shut down.
🔐
6 · Privacy & security
Data is on your device by default; end-to-end encryption becomes feasible because the server never needs to read it.
🗝️
7 · User ownership
You have ultimate control: copy it, back it up, move it to another tool. No API rate limit between you and your own data.
Why it matters in 2026
AI agents made this concrete: an agent working on your behalf wants fast, unmetered access to your data. A local replica gives agents the same superpower it gives UIs — reads and writes with no API round trip.
01 · Foundation
Cloud vs Local-First
Neither model is strictly better — they optimize for different things. Cloud-first centralizes trust and simplifies invariants; local-first buys latency, resilience, and collaboration at the cost of merge complexity.
Cloud-First
Every read is a fetch + spinner
Request waterfalls shape the UI
Offline = error page
Conflicts prevented by locking
State duplicated in caches (React Query, SWR) anyway
App dies when the company does
Local-First
Reads are instant, from disk
UI renders from one local store
Offline is a first-class mode
Conflicts merged by CRDTs
The local store is the cache — and the truth
Data outlives the backend
Honest trade-off
Local-first moves complexity, it does not delete it. You trade loading states and cache invalidation for merge semantics, schema migration on devices you do not control, and partial-replication policy. For apps dominated by server-side invariants — payments, inventory, auctions — cloud-first authority is still the simpler design.
02 · CRDTs
CRDT Basics
A Conflict-free Replicated Data Type is a data structure with a merge function guaranteeing that replicas which have seen the same set of updates converge to the same state — in any order, with no coordination and no central arbiter.
The trick is designing merge to be commutative, associative, and idempotent. If those three hold, the network can reorder, batch, and re-deliver updates freely and every device still lands on identical state.
G-CounterPN-CounterLWW-RegisterMV-RegisterOR-SetRGA / SequenceJSON / Map CRDTs
Last-Writer-Wins Register
The simplest useful CRDT: keep a timestamp (plus a client id as a tiebreaker) next to each value, and merge by taking the greater one. Deterministic on every device, no server required.
lww-register.ts
interface LWW<T> {
value: T;
ts: number; // hybrid logical clock or unix millis
client: string; // tiebreaker so merges are total-ordered
}
function merge<T>(a: LWW<T>, b: LWW<T>): LWW<T> {
if (a.ts !== b.ts) return a.ts > b.ts ? a : b;
return a.client > b.client ? a : b; // deterministic tie-break
}
// Two devices edit the same title while offline:
const laptop = { value: 'Q3 Plan', ts: 1700, client: 'A' };
const phone = { value: 'Q3 Roadmap', ts: 1712, client: 'B' };
merge(laptop, phone); // -> 'Q3 Roadmap'
merge(phone, laptop); // -> 'Q3 Roadmap' (commutative)
merge(phone, merge(phone, laptop)); // same (idempotent)
LWW loses data by design
Last-writer-wins resolves conflicts by discarding one side. That is fine for a title field, catastrophic for a counter or a text document. Choosing the right CRDT per field is the actual design work — a counter should merge by summing per-client increments, a set by unioning adds with tombstoned removes.
02 · CRDTs
Text & Sequence CRDTs
Collaborative text is the hard case: two people inserting at the same index must not overwrite each other, and everyone must converge on the same character order. Sequence CRDTs (RGA, Yjs's YATA, Automerge's columnar encoding) solve it by giving every character a stable identity instead of an index.
sequence-ids.txt
Index-based (breaks): Identity-based (converges):
"cat" "cat"
A: insert('h', at 1) A: insert 'h' after id(c@0,A)
B: insert('o', at 1) B: insert 'o' after id(c@0,A)
Apply in different orders Ids are stable; concurrent
-> "choat" vs "cohat" siblings ordered by (clock, client)
replicas diverge! -> every replica: "choat"
Each insert references the id of the character it comes after. When two inserts are concurrent siblings, a deterministic rule (logical clock, then client id) orders them — so replicas never diverge, and deletes become tombstones on ids rather than index shifts.
In practice
You will not implement this by hand. Yjs and Automerge ship production sequence CRDTs with binary encodings measured in bytes per keystroke, plus bindings for ProseMirror, CodeMirror, Monaco, and rich-text editors. The concept to keep:
identity over index.
Interleaving caveat
Naive sequence CRDTs can interleave two users typing a word at the same spot ("helworldlo"). Modern algorithms bias sibling ordering so each user's run stays contiguous — one of many reasons to use a battle-tested library.
02 · CRDTs
CRDTs vs OT vs Server Authority
Three ways to handle concurrent edits: transform operations against each other on a central server (OT — Google Docs), make the data structure itself mergeable (CRDTs), or serialize everything through one authority and reject conflicts (classic server-arbitrated).
OT / Server-Arbitrated
Needs an online server to order or transform ops
Offline editing is bolted on, sessions must replay
Transform functions are notoriously hard to prove correct
P2P and end-to-end encryption are impractical
CRDT
Merges anywhere — client, server, edge, peer
Offline-for-a-week then sync is the normal path
Correctness from algebraic laws, not case analysis
Works over any transport, including E2E-encrypted
When the server should stay boss
CRDTs guarantee convergence, not
invariants. They cannot enforce "usernames are unique" or "stock never goes below zero" without coordination. The pragmatic 2026 pattern is hybrid: CRDT-merge the collaborative surface (documents, canvases, notes) and route invariant-bearing writes through server-authoritative mutators.
03 · Sync Engines
Engine Landscape 2026
You rarely hand-roll sync anymore. The ecosystem has consolidated into a few serious engines with different centers of gravity: document CRDTs, Postgres replication, or query-driven sync.
🧬
Yjs
The workhorse document CRDT. Rich-text and whiteboard bindings, awareness/presence, y-websocket and P2P providers.
📜
Automerge
JSON-shaped CRDT with full edit history, Rust core, and a sync protocol designed for intermittent connections.
🐘
ElectricSQL
Postgres-native partial replication: declare shapes, stream them into a client store over HTTP.
0️⃣
Zero (Rocicorp)
Query-driven sync: the client subscribes to queries, the engine keeps results live and instant.
🔌
PowerSync
SQLite replicas of Postgres/MongoDB/MySQL with sync rules — popular for mobile fleets and spotty networks.
🎷
Jazz
Collaborative values with permissions and E2E encryption built into the data model itself.
📼
LiveStore
Event-sourced client store: writes are events, state is a SQLite materialization — reactive and replayable.
How to choose
Collaborative documents or canvases → Yjs or Automerge. Existing Postgres app that needs instant, offline-tolerant reads → ElectricSQL, Zero, or PowerSync. Greenfield app where permissions and sync should be one system → Jazz or LiveStore.
03 · Sync Engines
Partial Replication
Nobody syncs the whole database to a phone. Partial replication scopes the local replica to what this user can see and is likely to touch — expressed as shapes (Electric), sync rules (PowerSync), or live queries (Zero).
shapes.ts · Electric-style partial sync
// Declare WHAT to replicate, not how to fetch it.
const shape = await stream.subscribe({
table: 'issues',
where: 'project_id = $1 and archived = false',
params: [projectId],
columns: ['id', 'title', 'status', 'assignee_id'],
});
// The engine keeps the local store in sync from now on:
shape.onChange((rows) => db.upsert('issues', rows));
// UI code never fetches - it just queries the replica:
const open = db.query(
'select * from issues where status = $1 order by updated_at desc',
['open'],
);
The replica is also your authorization boundary: the server decides which rows enter a user's shape, so the device only ever holds data that user is allowed to read.
Design pressure
Shapes leak product decisions: "which projects sync eagerly?", "how much history?", "what happens when a row leaves the shape?". Budget real design time here — a too-big replica ruins first-load, a too-small one reintroduces spinners at the edges.
03 · Sync Engines
Optimistic Writes& Rollback
The write path mirrors the read path: apply the change to the local store immediately, enqueue it for the server, and reconcile when the authoritative result comes back. The UI re-renders from local state, so the user sees the effect in the same frame.
mutation.tsx
function useCloseIssue() {
return (issueId: string) => {
// 1. Apply locally - UI updates this frame
db.update('issues', issueId, { status: 'closed' });
// 2. Enqueue for the server (survives restarts)
outbox.push({ kind: 'close_issue', issueId, ts: clock.now() });
};
}
// 3. Background: push the outbox, reconcile the result
async function flushOutbox() {
for (const op of outbox.pending()) {
const result = await api.apply(op); // server re-runs the mutator
if (result.ok) {
outbox.confirm(op); // local + server agree
} else {
db.rollback(op); // rebase local state off the
toast('Issue reopened by ' + result.by); // authoritative outcome
}
}
}
The rebase mental model
Think git, not REST: local state is your working branch, the server confirms or rejects each commit, and rejected commits are rebased away. Engines like Zero and LiveStore automate exactly this loop — speculative local mutators, authoritative server replay.
UX rule
Rollbacks must be visible but not modal — a toast and an undo, not a blocking dialog. If a write can fail in a way the user truly must confirm first (payments), it should not be optimistic at all.
04 · Execution
Architecture Patterns
Every local-first stack, whatever the vendor, decomposes into the same five stages:
🗃️
Local store
SQLite / IndexedDB / doc
🔁
Sync engine
diff, merge, retry
☁️
Server
relay + authority
👥
Peers
other devices & users
Read Path vs Write Path
architecture.txt
READ PATH (synchronous, always available)
component -> live query -> local store
# no await, no spinner, re-renders on store change
WRITE PATH (asynchronous, eventually authoritative)
action -> local mutator -> local store (instant echo)
-> outbox -> sync engine -> server mutator
<- confirmed state / rejection <-
RULES OF THUMB
1. UI depends only on the local store
2. Every write goes through a named mutator (no raw table writes)
3. The server re-runs mutators it did not originate
4. Merge strategy chosen per field: CRDT, LWW, or server-wins
Migration path
You do not rewrite the app. Pick one high-interaction surface (the issue list, the editor, the inbox), give it a replica + outbox, and keep the rest on request/response. Linear shipped this way — sync for the core object graph, plain APIs around it.
04 · Execution
Local-First Checklist
Click items to mark complete. Progress tracked per session.
COMPLETION
0 / 10 complete
Data Layer
All reads come from a local store (SQLite, IndexedDB, in-memory doc) — never awaited from the networkCritical
Writes apply locally first and are queued for sync — the UI never blocks on the serverCritical
Every record carries identity + causality metadata (client id, lamport clock or timestamp)CRDT
Sync
Sync engine reconnects and resumes automatically after offline periodsSync
Partial replication scoped to what the user can see (shapes / queries), not the whole databaseSync
Concurrent edits merge deterministically on every device (test two devices editing offline)CRDT
Server-authoritative escape hatch for invariants CRDTs cannot express (uniqueness, inventory)Design
Product
App is fully usable in airplane mode — verified in CI or manual test passCritical
Data is exportable in an open format — the file outlives the appLongevity
Presence & multiplayer states degrade gracefully when peers are offlineDesign