Local-First Software: Principles, Patterns, and Technologies
Table of Contents
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.
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
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 |
Conflict-Free Replicated Data Types (CRDTs)
CRDTs are the foundation of local-first sync. They allow concurrent edits to merge automatically without conflicts.
Types of CRDTs
State-based (CvRDTs)
- Replicate entire state
- Merge via join semilattice
- Simpler but higher bandwidth
Operation-based (CmRDTs)
- Replicate operations
- Require reliable broadcast
- Lower bandwidth
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 |
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 )
Synchronization Technologies
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 } // ]
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!')
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);
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: '...' }
})
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')
Architecture Patterns
Event Sourcing with CRDTs
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Device A │────▶│ Event Log │────▶│ Device B │
│ │◀────│ (CRDT) │◀────│ │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Local State │ │ Sync Layer │ │ Local State │
└─────────────┘ └─────────────┘ └─────────────┘
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()
UI Patterns for Connectivity States
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>
}
}
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')
}
}
Security and Identity
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())
Decentralized Identity
Local-first apps often use:
- DIDs (Decentralized Identifiers)
- Public key cryptography for identity
- Web of trust for authorization
Practical Example: Offline Note-Taking App
Project Structure
local-notes/ ├── src/ │ ├── db/ │ │ ├── schema.sql │ │ └── migrations/ │ ├── sync/ │ │ ├── crdt.py │ │ └── protocol.py │ ├── ui/ │ │ └── app.py │ └── main.py ├── tests/ └── requirements.txt
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 );
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 ]
Resources
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
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 |
Conferences and Talks
- Local-First Conf - Annual conference
- CRDTs: The Hard Parts - Martin Kleppmann
- Local-first software - Peter van Hardenberg
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
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