Local-First Software: Principles, Patterns, and Technologies
Table of Contents
- 1. Introduction
- 2. Conflict-Free Replicated Data Types (CRDTs)
- 3. Synchronization Technologies
- 4. Architecture Patterns
- 5. UI Patterns for Connectivity States
- 6. Security and Identity
- 7. Practical Example: Offline Note-Taking App
- 8. Resources
- 9. Conclusion
- 10. Related notes
- 11. Postscript (2026 Q1)
- 12. Postscript (2026 Q2)
1. Introduction
Local-first software prioritizes keeping data on the user's device while enabling collaboration when connected. Unlike cloud-first applications that treat the server as the source of truth, local-first apps work fully offline and sync when possible.
1.1. Core Principles
From the seminal Ink & Switch paper, the seven ideals of local-first software:
- No spinners: Work is local, so apps are fast
- Your work is not hostage: Data lives on your devices
- Network optional: Full functionality offline
- Seamless collaboration: Real-time sync when online
- Long-term preservation: Data outlives applications
- Security and privacy: End-to-end encryption by default
- User control: You own your data
1.2. Why Local-First Matters
| Traditional Cloud | Local-First |
|---|---|
| Server is truth | Device is truth |
| Requires connection | Works offline |
| Vendor lock-in | Data portability |
| Latency dependent | Instant response |
| Privacy concerns | User-controlled |
| Subscription model | Own your tools |
2. Conflict-Free Replicated Data Types (CRDTs)
CRDTs are the foundation of local-first sync. They allow concurrent edits to merge automatically without conflicts.
// Local-first sync -- two replicas diverge offline, reconcile through CRDT merge, converge on synced state
digraph local_first_sync {
rankdir=LR;
graph [bgcolor="white", fontname="Helvetica", fontsize=11,
pad="0.3", nodesep="0.3", ranksep="0.45"];
node [shape=box, style="rounded,filled", fontname="Helvetica",
fontsize=10, fillcolor="#dbeafe", color="#888", fontcolor="#555"];
edge [color="#888", fontcolor="#555"];
// Replica A path
subgraph cluster_a {
label="Replica A"; labeljust="l"; color="#1d4ed8";
fontcolor="#1d4ed8"; style="rounded";
a_off [label="Offline"];
a_edit [label="Editing locally"];
a_pend [label="Pending sync\n(op log queued)"];
}
// Replica B path
subgraph cluster_b {
label="Replica B"; labeljust="l"; color="#15803d";
fontcolor="#15803d"; style="rounded";
b_off [label="Offline"];
b_edit [label="Editing locally"];
b_pend [label="Pending sync\n(op log queued)"];
}
// Shared reconciliation
subgraph cluster_merge {
label="Reconcile"; labeljust="l"; color="#b45309";
fontcolor="#b45309"; style="rounded";
merge [label="Merging\n(CRDT join /\nvector clock)"];
resolved [label="Resolved\n(converged state)"];
}
// Synced steady state
subgraph cluster_sync {
label="Steady"; labeljust="l"; color="#6b21a8";
fontcolor="#6b21a8"; style="rounded";
synced [label="Synced"];
}
// Replica A lifecycle
a_off -> a_edit [label="user typing"];
a_edit -> a_pend [label="autosave"];
a_pend -> merge [label="connectivity"];
// Replica B lifecycle
b_off -> b_edit [label="user typing"];
b_edit -> b_pend [label="autosave"];
b_pend -> merge [label="connectivity"];
// Conflict-resolution branch -- both replicas funnel through merge, then resolved
merge -> resolved [label="commutative\nmerge", color="#b91c1c", fontcolor="#b91c1c"];
resolved -> synced [label="ack"];
// Cycles back: synced may drop offline, or user keeps editing
synced -> a_off [label="A drops", style=dashed];
synced -> b_off [label="B drops", style=dashed];
synced -> a_edit [label="A keeps editing", style=dashed];
synced -> b_edit [label="B keeps editing", style=dashed];
}
2.1. Types of CRDTs
2.1.1. State-based (CvRDTs)
- Replicate entire state
- Merge via join semilattice
- Simpler but higher bandwidth
2.1.2. Operation-based (CmRDTs)
- Replicate operations
- Require reliable broadcast
- Lower bandwidth
2.2. Common CRDT Structures
| Structure | Use Case | Example |
|---|---|---|
| G-Counter | Increment-only counter | View counts |
| PN-Counter | Increment/decrement | Likes/dislikes |
| LWW-Register | Last-writer-wins value | User preferences |
| OR-Set | Add/remove set | Tags, labels |
| RGA | Ordered list/text | Collaborative docs |
2.3. Simple G-Counter Example
class GCounter:
"""Grow-only counter CRDT."""
def __init__(self, node_id: str):
self.node_id = node_id
self.counts: dict[str, int] = {}
def increment(self, amount: int = 1) -> None:
current = self.counts.get(self.node_id, 0)
self.counts[self.node_id] = current + amount
def value(self) -> int:
return sum(self.counts.values())
def merge(self, other: "GCounter") -> None:
for node_id, count in other.counts.items():
self.counts[node_id] = max(
self.counts.get(node_id, 0),
count
)
3. Synchronization Technologies
3.1. Automerge
Automerge provides JSON-like documents with automatic merging.
import * as Automerge from '@automerge/automerge'
// Create a document
let doc = Automerge.init()
doc = Automerge.change(doc, d => {
d.tasks = []
d.tasks.push({ title: "Learn CRDTs", done: false })
})
// Concurrent changes merge automatically
let doc1 = Automerge.change(doc, d => {
d.tasks[0].done = true
})
let doc2 = Automerge.change(doc, d => {
d.tasks.push({ title: "Build app", done: false })
})
// Merge produces correct result
let merged = Automerge.merge(doc1, doc2)
// merged.tasks = [
// { title: "Learn CRDTs", done: true },
// { title: "Build app", done: false }
// ]
3.2. Yjs
Yjs is optimized for real-time collaborative editing.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const ydoc = new Y.Doc()
// Shared types
const ytext = ydoc.getText('editor')
const yarray = ydoc.getArray('items')
const ymap = ydoc.getMap('state')
// Connect to sync server
const provider = new WebsocketProvider(
'wss://sync.example.com',
'room-id',
ydoc
)
// Local changes sync automatically
ytext.insert(0, 'Hello, ')
ytext.insert(7, 'world!')
3.3. Loro
Loro is a newer CRDT library with rich text support.
use loro::LoroDoc;
let doc = LoroDoc::new();
let text = doc.get_text("content");
// Rich text with formatting
text.insert(0, "Hello");
text.mark(0..5, "bold", true);
3.4. Electric SQL
Electric SQL syncs SQLite databases.
import { electrify } from 'electric-sql/browser'
import { schema } from './generated/client'
const electric = await electrify(db, schema)
// Sync specific tables
await electric.sync({
include: {
notes: true,
tags: true
}
})
// Use like normal SQLite
await db.notes.create({
data: { title: 'Meeting notes', content: '...' }
})
3.5. PowerSync
PowerSync provides offline-first sync for React Native and Flutter.
import { PowerSyncDatabase } from '@powersync/web'
const db = new PowerSyncDatabase({
schema: appSchema,
database: { dbFilename: 'app.db' }
})
await db.connect(connector)
// Queries work offline
const notes = await db.getAll('SELECT * FROM notes WHERE archived = 0')
4. Architecture Patterns
4.2. Sync Protocol Design
class SyncProtocol:
"""Basic sync protocol for local-first apps."""
def __init__(self, local_store, remote_endpoint):
self.local = local_store
self.remote = remote_endpoint
self.vector_clock = {}
async def sync(self):
# 1. Get local changes since last sync
local_changes = self.local.changes_since(self.vector_clock)
# 2. Send to server, receive remote changes
remote_changes = await self.remote.exchange(
local_changes,
self.vector_clock
)
# 3. Merge remote changes into local state
for change in remote_changes:
self.local.apply(change)
# 4. Update vector clock
self.vector_clock = self.local.current_clock()
5. UI Patterns for Connectivity States
5.1. Connection State Management
type ConnectionState =
| { status: 'online'; latency: number }
| { status: 'offline' }
| { status: 'syncing'; progress: number }
| { status: 'error'; message: string }
function ConnectionIndicator({ state }: { state: ConnectionState }) {
switch (state.status) {
case 'online':
return <Badge color="green">Online</Badge>
case 'offline':
return <Badge color="gray">Offline</Badge>
case 'syncing':
return <Badge color="blue">Syncing {state.progress}%</Badge>
case 'error':
return <Badge color="red">{state.message}</Badge>
}
}
5.2. Optimistic Updates
async function saveNote(note: Note) {
// 1. Update local state immediately
localStore.save(note)
updateUI(note)
// 2. Queue for sync
syncQueue.add({
type: 'save_note',
data: note,
timestamp: Date.now()
})
// 3. Attempt sync (may fail if offline)
try {
await syncQueue.flush()
} catch (e) {
// Changes are persisted locally, will sync later
console.log('Queued for later sync')
}
}
6. Security and Identity
6.1. End-to-End Encryption
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
def derive_key(password: str, salt: bytes) -> bytes:
"""Derive encryption key from user password."""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=480000,
)
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
def encrypt_document(doc: dict, key: bytes) -> bytes:
"""Encrypt document for storage/sync."""
f = Fernet(key)
return f.encrypt(json.dumps(doc).encode())
6.2. Decentralized Identity
Local-first apps often use:
- DIDs (Decentralized Identifiers)
- Public key cryptography for identity
- Web of trust for authorization
7. Practical Example: Offline Note-Taking App
7.1. Project Structure
local-notes/
├── src/
│ ├── db/
│ │ ├── schema.sql
│ │ └── migrations/
│ ├── sync/
│ │ ├── crdt.py
│ │ └── protocol.py
│ ├── ui/
│ │ └── app.py
│ └── main.py
├── tests/
└── requirements.txt
7.2. SQLite Schema
-- Notes table with CRDT metadata
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
vector_clock TEXT, -- JSON encoded
deleted INTEGER DEFAULT 0
);
-- Pending sync operations
CREATE TABLE sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation TEXT NOT NULL,
data TEXT NOT NULL,
created_at INTEGER NOT NULL,
synced INTEGER DEFAULT 0
);
-- Track sync state
CREATE TABLE sync_state (
key TEXT PRIMARY KEY,
value TEXT
);
7.3. Core Implementation
import sqlite3
import json
import uuid
from datetime import datetime
from dataclasses import dataclass, asdict
@dataclass
class Note:
id: str
title: str
content: str
created_at: int
updated_at: int
vector_clock: dict
deleted: bool = False
@classmethod
def create(cls, title: str, content: str = "") -> "Note":
now = int(datetime.now().timestamp() * 1000)
return cls(
id=str(uuid.uuid4()),
title=title,
content=content,
created_at=now,
updated_at=now,
vector_clock={},
deleted=False
)
class NotesDatabase:
def __init__(self, db_path: str = "notes.db"):
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
self._init_schema()
def _init_schema(self):
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
vector_clock TEXT,
deleted INTEGER DEFAULT 0
);
""")
self.conn.commit()
def save(self, note: Note) -> None:
self.conn.execute("""
INSERT OR REPLACE INTO notes
(id, title, content, created_at, updated_at, vector_clock, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
note.id, note.title, note.content,
note.created_at, note.updated_at,
json.dumps(note.vector_clock),
1 if note.deleted else 0
))
self.conn.commit()
def get(self, note_id: str) -> Note | None:
row = self.conn.execute(
"SELECT * FROM notes WHERE id = ?", (note_id,)
).fetchone()
if row:
return Note(
id=row["id"],
title=row["title"],
content=row["content"],
created_at=row["created_at"],
updated_at=row["updated_at"],
vector_clock=json.loads(row["vector_clock"] or "{}"),
deleted=bool(row["deleted"])
)
return None
def list_active(self) -> list[Note]:
rows = self.conn.execute(
"SELECT * FROM notes WHERE deleted = 0 ORDER BY updated_at DESC"
).fetchall()
return [
Note(
id=row["id"],
title=row["title"],
content=row["content"],
created_at=row["created_at"],
updated_at=row["updated_at"],
vector_clock=json.loads(row["vector_clock"] or "{}"),
deleted=False
)
for row in rows
]
8. Resources
8.1. Essential Reading
- Local-first software - The foundational paper by Ink & Switch
- crdt.tech - CRDT resources and papers
- Designing Data-Intensive Applications - Martin Kleppmann's book
8.2. Libraries and Frameworks
| Library | Language | Focus |
|---|---|---|
| Automerge | JS/Rust | JSON documents |
| Yjs | JavaScript | Real-time collaboration |
| Loro | Rust/JS | Rich text CRDTs |
| Electric SQL | TypeScript | SQLite sync |
| PowerSync | Various | Mobile sync |
| Replicache | TypeScript | Optimistic sync |
8.3. Conferences and Talks
- Local-First Conf - Annual conference
- CRDTs: The Hard Parts - Martin Kleppmann
- Local-first software - Peter van Hardenberg
8.4. Research Papers
- Shapiro et al. (2011): Conflict-free Replicated Data Types
- Kleppmann et al. (2019): Interleaving anomalies in collaborative text editors
- Sun et al. (1998): Operational Transformation
9. Conclusion
Local-first development represents a fundamental shift in how we build collaborative software. By prioritizing user ownership, offline capability, and seamless sync, we can create applications that are faster, more reliable, and more respectful of user privacy.
Key takeaways:
- Start with CRDTs: Choose the right data structures for your use case
- Design for offline: Treat network as enhancement, not requirement
- Embrace eventual consistency: Users understand "syncing" if the UI is clear
- Secure by default: End-to-end encryption protects user data
- Use existing libraries: Automerge, Yjs, and others are battle-tested
11. Postscript (2026 Q1)
The libraries referenced above continued to harden through 2025 and into 2026. Automerge 3.0 (May 2025) cut memory use roughly 10x with a Rust core and a stable JS API, finally making large documents practical in the browser. Yjs remains the dominant choice for real-time text editors (Tiptap, BlockNote, Notion-style), while Loro hit a 1.0 release in 2024 with rich-text and movable-tree CRDTs that target the gaps Automerge and Yjs leave open. On the sync-engine side, ElectricSQL pivoted in 2024 to a server-only "Electric Next" model that streams Postgres tables to clients without requiring SQLite-side CRDTs, and PowerSync now ships native SDKs for React Native, Flutter, web, and Kotlin Multiplatform. The pattern crystallised by Riffle (signals + reactive SQL) shows up in LiveStore and similar event-sourced local-first stacks. The interesting 2026 question is no longer "CRDT or OT?" but "which sync engine boundary?" — replicate Postgres rows, replicate document ops, or replicate event logs — and what that choice does to schema migrations and multi-device identity.
12. Postscript (2026 Q2)
Three developments since the Q1 update:
12.1. Local-first extends to inference
The local-first invariants (device is truth, network optional, user
controls data) now apply to LLM inference, not just collaborative
data. Qwen3.6's hybrid attention architecture reduces KV-bearing
layers from 64 to 16; llama.cpp's TurboQuant compresses the
remaining KV cache ~4.6x. Together they give ~18x KV reduction,
making 256K-token context fit alongside model weights on a 24–32GB
consumer machine. The contract is the same: weights fit, context
fits alongside, decode runs at memory-bandwidth speed. The
constraint that was binding — KV cache scaling linearly with
context — is now attacked from two orthogonal directions.
12.2. Agent memory is a CRDT problem
Cloudflare's Agent Memory (April 2026) gives each agent a Durable
Object identity with its own SQLite and a read/write/search/load
interface. This is a sync-engine with one replica per agent. When
multiple agents edit the same context concurrently — which is what
subagent delegation produces — the merge semantics are exactly the
CRDT problem this note describes. The "which sync engine boundary?"
question from Q1 now applies to agent state, not just user
documents.
12.3. The collaborative-editing counter-narrative
Moment.dev's "Lies I was told about Collaborative Editing, Part 2" argues against Yjs specifically: the abstraction leaks when you need undo/redo stacks, selection state, and cursor awareness that the CRDT doesn't model. The practical claim is that CRDTs solve convergence but not collaboration, and the gap is filled by application-level protocol on top. This is a refinement, not a refutation: the CRDT is necessary infrastructure, but "just use Yjs" is insufficient design guidance.