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:

StorageGood ForWatch Out
IndexedDBCompatibility, moderate dataNo SQL, verbose
OPFS + SQLite WASMRelations/queriesSafari bugs, bundle size
PGlitePostgres compatMaturing, 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.)