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:

  1. No spinners: Work is local, so apps are fast
  2. Your work is not hostage: Data lives on your devices
  3. Network optional: Full functionality offline
  4. Seamless collaboration: Real-time sync when online
  5. Long-term preservation: Data outlives applications
  6. Security and privacy: End-to-end encryption by default
  7. 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

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

Research Papers

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:

  1. Start with CRDTs: Choose the right data structures for your use case
  2. Design for offline: Treat network as enhancement, not requirement
  3. Embrace eventual consistency: Users understand "syncing" if the UI is clear
  4. Secure by default: End-to-end encryption protects user data
  5. Use existing libraries: Automerge, Yjs, and others are battle-tested

Author: Jason Walsh

j@wal.sh

Last Updated: 2025-12-22 23:41:19

build: 2025-12-24 17:38 | sha: 5b2a311