Local-First as Distributed Data Ownership, Not Just Offline
Local-first treats the client as a full node in a distributed system with its own database, enabling instant reads/writes and background sync. Unlike offline-first (server as truth) or PWAs (delivery mechanism), data lives primarily on the device. The author shifted after a demo failure on bad Wi-Fi, realizing traditional stacks (React/Node/Postgres/GraphQL) force round-trips. Inspired by Ink & Switch's 2019 paper, which outlined ideals like fast/multi-device/offline/collaboration/longevity/privacy/ownership, now practical in 2026 with mature tools.
Key mental model: Git for app data. Clients hold replicas; writes commit locally; sync is push/pull. No React Query/SWR needed—local DB is state. UI renders from DB directly, eliminating spinners/optimistic updates.
"The client is not a thin view requesting permission to show data. The client is a node in a distributed system with its own database." (Core distinction from paper, reshaping stack from request/response to local-first.)
Skip Local-First for Server-Generated Data or Strict Consistency
Don't force it: Wrong for server-produced data (analytics, feeds, search) where client consumes, not owns. Avoid in strong consistency needs (banking/inventory) due to eventual consistency risks—ACID servers win. Overkill for simple CRUD/office apps; impractical for huge datasets.
Shines for user-generated data: notes/docs/project mgmt/field apps/privacy-focused/collab tools. Start small—hybrid: local-first for offline drafts or collab notes in traditional apps. Author ripped it from two projects, wasted 6 weeks on analytics dashboard.
"The data is generated on the server. There’s nothing to replicate to the client. What are you doing?" (Colleague's pull-aside, highlighting misapplication to non-replicable data.)
Spectrum exists: Begin with one feature to test fit without full rewrite.
Client Storage: SQLite WASM/OPFS Over IndexedDB
Ditch localStorage (sync, tiny, strings-only). IndexedDB: async, big, but miserable API—no SQL.
2026 winner: SQLite via WASM + OPFS for real relational DB (queries/transactions/indexes). OPFS provides sandboxed sync file access in Workers. Init example with wa-sqlite:
import { SQLiteAPI } from 'wa-sqlite';
import { OPFSCoopSyncVFS } from 'wa-sqlite/src/examples/OPFSCoopSyncVFS.js';
async function initDatabase() {
const module = await SQLiteAPI.initialize();
const vfs = new OPFSCoopSyncVFS('pm-tool-db');
await vfs.initialize(module);
const db = await module.open_v2('workspace.db');
await module.exec(db, `PRAGMA journal_mode=WAL`);
// Schema for tasks table...
return db;
}
Wrap writes in queue (Safari concurrency issues). Log failed SQL to Sentry. Safari OPFS quirks (silent fails in iframes)—fallback to IndexedDB. Bundle +400KB.
Alternatives:
| Storage | Good For | Watch Out |
|---|---|---|
| IndexedDB | Compatibility, moderate data | No SQL, verbose |
| OPFS + SQLite WASM | Relations/queries | Safari bugs, bundle size |
| PGlite | Postgres compat | Maturing, larger |
Tried cr-sqlite (CRDT columns)—too early, surprising merges.
Sync Strategies: Replication for Most, CRDTs for Real-Time Text
Hard part: Reliable multi-device/user sync.
CRDTs: Math-guaranteed merges. Yjs best for JS/real-time collab (docs). Setup:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('wss://sync.our-app.dev', 'workspace-a1b2c3d4', ydoc);
const tasks = ydoc.getMap('tasks');
// Mutations via Y.Map, observeDeep for UI (debounce 16ms)
Automerge (Rust/doc-oriented), Loro (newer Rust/perf)—less experience.
DB Replication: Better for non-text. PowerSync (Postgres→SQLite one-way + writeback, stable production). ElectricSQL (active-active, prototypes). Triplit (full-stack sync DB, nice DX prototype).
Event Sourcing: LiveStore syncs logs. Appealing but complex state rebuild—overkill for most apps like task boards.
Author shipped 3 apps: Yjs for collab editor (good, pain points later); PowerSync production-stable over ElectricSQL.
Field-Level LWW + Server Validation for Conflicts
Manageable, not terrifying. 95% handled by last-write-wins (LWW) per field (timestamp + clientId tiebreaker), not record. Keep divergent fields (title vs due date).
function pickWinner(a: FieldValue, b: FieldValue): FieldValue {
const timeA = new Date(a.updatedAt).getTime();
const timeB = new Date(b.updatedAt).getTime();
if (timeA !== timeB) return timeA > timeB ? a : b;
return a.clientId > b.clientId ? a : b;
}
function mergeTask(local: Record, remote: Record) {
// Per-field merge logic
}
Same-field: LWW wins silently (fine for titles, CRDTs for docs).
Semantic conflicts (double-bookings): App-level server validation during sync. Accept+flag violations (not reject—avoids divergence). Push violations back; client shows resolvable notifications.
// validateSyncBatch: Check invariants (e.g., overlaps), flag SyncViolation, accept anyway
Tried rejection—led to ghost records. Window of invalid state tolerable for meetings, not inventory.
"Local-first web development is Git for application data." (Analogy clicking replicas/commits/sync, simplifying mental model from centralized SVN.)
Key Takeaways
- Evaluate fit early: User-gen/offline/collab yes; server-data/consistency no. Start with one feature.
- Use SQLite WASM/OPFS (wa-sqlite) for client DB—queue writes, Sentry logs, Safari fallbacks.
- Sync: PowerSync for relational replication; Yjs CRDTs for real-time text.
- Conflicts: Field-level LWW covers 95%; server-validate semantics, flag not reject.
- No fetching/state libs needed—local DB is state. Instant UI, background sync.
- Prototype hybrids: Local-first features in traditional apps.
- Debug browser DBs ruthlessly—telemetry essential.
"The “spectrum of local-first” is a real thing, and starting with one feature is how I’d recommend anyone begin." (Practical entry point, avoiding all-in overcommitment.)