Local-First Architecture — When Web Apps Stop Waiting for the Server

Posted on: 4/26/2026 9:12:52 PM

Have you ever clicked a button in a web app and waited 200-500ms for the server to respond? With Local-First architecture, that response time drops to under 1ms — because the data already lives on your device. No internet required, no waiting, the app responds instantly like native desktop software.

< 1msLocal data read latency
3MBPGlite size (gzipped)
50+PostgreSQL extensions supported
100%Offline functionality

What is Local-First?

Local-First is a software architecture model where data is stored and processed primarily on the user's device (browser, mobile, desktop), then synced in the background to a server when a network connection is available. Unlike traditional client-server architecture where every operation requires a server request, Local-First inverts this model: reads and writes happen instantly on local storage, synchronization comes after.

Core Principles of Local-First

1. Instant Response: All read/write operations respond immediately, independent of network.
2. Offline-First: The app works fully without internet connectivity.
3. Background Sync: Data syncs to server when connected, never blocking the UI.
4. Conflict Resolution: Conflicts between multiple devices are resolved automatically using CRDTs.

Why Cloud-Only is Hitting Its Limits

The cloud-only model (client sends every request to a server) faces 3 major limitations in practice:

IssueCloud-OnlyLocal-First
Latency100-500ms per request (depends on server distance)< 1ms (read/write directly on device)
OfflineNon-functional when disconnectedFully functional, syncs later
Data SecurityCentralized — large attack targetDistributed across devices — reduced risk
Server CostScales linearly with request countOnly syncs deltas — significantly less bandwidth
Data OwnershipData lives on provider's cloudData lives on user's device

Local-First Architecture Overview

graph TD
    subgraph Client["🖥️ Client (Browser/Mobile)"]
        UI["UI Layer
Vue / React"] LQ["Live Query
Reactive Binding"] PG["PGlite
PostgreSQL WASM"] IDB["IndexedDB / OPFS
Persistent Storage"] end subgraph Sync["🔄 Sync Layer"] SE["Sync Engine
ElectricSQL / Zero"] CR["CRDT Resolver
Conflict Resolution"] end subgraph Server["☁️ Server"] API["API Gateway"] PGS["PostgreSQL
Source of Truth"] WAL["WAL Stream
Change Data Capture"] end UI --> LQ LQ --> PG PG --> IDB PG <--> SE SE <--> CR SE <--> WAL WAL --> PGS API --> PGS style UI fill:#e94560,stroke:#fff,color:#fff style PG fill:#336791,stroke:#fff,color:#fff style PGS fill:#336791,stroke:#fff,color:#fff style SE fill:#4CAF50,stroke:#fff,color:#fff style CR fill:#ff9800,stroke:#fff,color:#fff style LQ fill:#2196F3,stroke:#fff,color:#fff style IDB fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style API fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50 style WAL fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

Local-First architecture: data is read/written on PGlite (client), synced via Sync Engine to PostgreSQL (server)

PGlite — PostgreSQL Running on WebAssembly

PGlite is a full WebAssembly build of PostgreSQL, developed by the ElectricSQL team. Instead of running a separate database server, PGlite embeds Postgres directly into your JavaScript/TypeScript application as an in-process library — only 3MB gzipped.

Installation and Initialization

npm install @electric-sql/pglite
import { PGlite } from '@electric-sql/pglite';

// In-memory database (lost on refresh)
const db = new PGlite();

// Persistent with IndexedDB (survives sessions)
const db = new PGlite('idb://my-app-db');

// Persistent with OPFS (Origin Private File System — faster than IndexedDB)
const db = new PGlite('opfs://my-app-db');

Full SQL Support

PGlite supports nearly all PostgreSQL SQL syntax, including JOINs, subqueries, window functions, CTEs, and JSON operators:

// Create tables with full constraints
await db.exec(`
  CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    completed BOOLEAN DEFAULT false,
    priority INTEGER CHECK (priority BETWEEN 1 AND 5),
    created_at TIMESTAMP DEFAULT NOW(),
    metadata JSONB DEFAULT '{}'
  )
`);

// Insert data
await db.query(
  'INSERT INTO tasks (title, priority, metadata) VALUES ($1, $2, $3)',
  ['Deploy Local-First app', 3, JSON.stringify({ tags: ['devops', 'pglite'] })]
);

// Complex queries — running entirely in the browser
const result = await db.query(`
  SELECT
    priority,
    COUNT(*) as total,
    COUNT(*) FILTER (WHERE completed) as done,
    ROUND(COUNT(*) FILTER (WHERE completed)::numeric / COUNT(*) * 100, 1) as pct
  FROM tasks
  GROUP BY priority
  ORDER BY priority
`);
console.log(result.rows);
// [{ priority: 1, total: 5, done: 3, pct: 60.0 }, ...]

Extensions — pgvector in the Browser

PGlite supports dynamic extension loading, including pgvector for vector search and PostGIS for geospatial — running entirely on the client:

import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';

const db = new PGlite({
  extensions: { vector }
});

await db.exec('CREATE EXTENSION IF NOT EXISTS vector');
await db.exec(`
  CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    content TEXT,
    embedding vector(384)
  )
`);

// Similarity search right in the browser — no server calls!
const similar = await db.query(`
  SELECT content, embedding <=> $1::vector AS distance
  FROM documents
  ORDER BY distance
  LIMIT 5
`, [queryEmbedding]);

When to Use PGlite?

Good fit: Apps needing offline support, personal productivity tools, collaborative editors, AI-powered local search, rapid prototyping.
Not ideal: Apps querying shared global data (company-wide analytics dashboards), write-heavy workloads exceeding client device capabilities.

CRDTs — Resolving Conflicts Without a Server

Conflict-free Replicated Data Types (CRDTs) are the mathematical foundation enabling multiple devices to edit the same data concurrently without a central server to arbitrate. CRDTs guarantee that all replicas converge to the same state through mathematical properties: commutativity, associativity, and idempotency.

sequenceDiagram
    participant A as Device A
    participant S as Sync Server
    participant B as Device B

    Note over A,B: Both devices offline
    A->>A: Add task "Deploy v2"
    B->>B: Add task "Fix bug #42"
    A->>A: Mark "Setup CI" = done

    Note over A,B: Connection restored
    A->>S: Sync delta changes
    B->>S: Sync delta changes
    S->>S: CRDT merge (auto)
    S->>A: Merged state
    S->>B: Merged state

    Note over A,B: Both have identical state
no conflicts, no data loss

CRDTs allow multiple devices to edit offline, then auto-merge without data loss

Common CRDT Types

CRDT TypeDescriptionUse Case
G-CounterGrow-only counter, each node has its own counterView counts, like counts
LWW-RegisterLast-Writer-Wins — uses timestamps to resolveProfile fields, settings
OR-SetObserved-Remove Set — add/remove without conflictsTag lists, todo lists
RGAReplicated Growable Array — ordered listCollaborative text editing
Fractional IndexFractional indexing — insert between two elementsKanban boards, drag-and-drop reorder

Sync Engines — Bridging Local and Cloud

A Sync Engine is the middleware layer responsible for synchronizing data between the local database (PGlite/SQLite) and the server database (PostgreSQL). This is the most critical component determining the real-time experience of your application.

Sync EngineStorageSync MechanismStrength
ElectricSQLPGlite / SQLiteWAL-based replication streamDeep PostgreSQL integration, shape-based sync
Zero (Rocicorp)Custom storeIncremental view maintenanceRealtime multi-user, Figma-style collaboration
AutomergeBinary formatOperation-based CRDTCollaborative documents, rich merge semantics
YjsShared typesDifferential syncBest rich text editing performance
PowerSyncSQLiteBucket-based sync rulesReact Native / Flutter mobile-first

ElectricSQL + PGlite in Practice

import { PGlite } from '@electric-sql/pglite';
import { electricSync } from '@electric-sql/pglite-sync';
import { live } from '@electric-sql/pglite/live';

const db = new PGlite({
  dataDir: 'idb://my-app',
  extensions: {
    electric: electricSync(),
    live
  }
});

// Define a "shape" — only sync the data you need
await db.electric.syncShapeToTable({
  shape: {
    url: 'https://api.myapp.com/v1/shape',
    params: { table: 'tasks', where: 'user_id = 42' }
  },
  table: 'tasks',
  primaryKey: ['id']
});

// Live query — UI auto-updates when data changes
const unsubscribe = db.live.query(
  'SELECT * FROM tasks WHERE completed = false ORDER BY priority',
  [],
  (results) => {
    // Callback fires on every change
    renderTaskList(results.rows);
  }
);

Integration with Vue.js

Local-First pairs exceptionally well with Vue.js thanks to its built-in reactivity system. PGlite's live queries combined with Vue's ref() and watchEffect() create a seamless experience:

// composables/usePGlite.ts
import { ref, onMounted, onUnmounted } from 'vue';
import { PGlite } from '@electric-sql/pglite';
import { live } from '@electric-sql/pglite/live';

let db: PGlite | null = null;

export async function getDB() {
  if (!db) {
    db = new PGlite({
      dataDir: 'idb://vue-app',
      extensions: { live }
    });
    await db.exec(`
      CREATE TABLE IF NOT EXISTS notes (
        id SERIAL PRIMARY KEY,
        title TEXT NOT NULL,
        content TEXT DEFAULT '',
        updated_at TIMESTAMP DEFAULT NOW()
      )
    `);
  }
  return db;
}

export function useLiveQuery<T>(sql: string, params: any[] = []) {
  const rows = ref<T[]>([]);
  const loading = ref(true);
  let unsub: (() => void) | null = null;

  onMounted(async () => {
    const db = await getDB();
    unsub = db.live.query(sql, params, (result) => {
      rows.value = result.rows as T[];
      loading.value = false;
    });
  });

  onUnmounted(() => unsub?.());

  return { rows, loading };
}
<!-- NoteList.vue -->
<script setup lang="ts">
import { useLiveQuery, getDB } from './composables/usePGlite';

interface Note { id: number; title: string; content: string; updated_at: string }

const { rows: notes, loading } = useLiveQuery<Note>(
  'SELECT * FROM notes ORDER BY updated_at DESC'
);

async function addNote() {
  const db = await getDB();
  await db.query(
    'INSERT INTO notes (title) VALUES ($1)',
    [`Note ${Date.now()}`]
  );
  // No need to update state — the live query reacts automatically!
}
</script>

<template>
  <div>
    <button @click="addNote">+ New Note</button>
    <p v-if="loading">Loading...</p>
    <ul v-else>
      <li v-for="note in notes" :key="note.id">
        {{ note.title }}
      </li>
    </ul>
  </div>
</template>

How This Differs from Pinia/Vuex

With Local-First, you don't need a separate state management library. PGlite + live queries serve as both persistent storage and reactive state. Data automatically survives page refreshes, and multiple tabs/windows share the same database.

Storage Backends — IndexedDB vs OPFS

PGlite supports multiple storage backends for persistent data in the browser:

BackendWrite SpeedCapacityBrowser SupportWhen to Use
In-memoryFastestRAM limitedAllTesting, prototyping, ephemeral data
IndexedDBModerateHundreds of MBAllDefault choice, widest compatibility
OPFSFast (native file I/O)Multiple GBChrome, Edge, FirefoxData-intensive applications
File SystemFastestDisk limitNode/Bun/DenoServer-side, CLI tools, CI

Note on OPFS

OPFS (Origin Private File System) delivers superior performance over IndexedDB but requires running in a Web Worker (cannot be used on the main thread). If your application needs OPFS, initialize PGlite in a dedicated worker and communicate via message passing.

Migration Strategy: Cloud-Only to Local-First

No need to rewrite your entire application. Apply a Hybrid-First strategy — migrate incrementally:

graph LR
    subgraph Phase1["Phase 1: Cache Layer"]
        A1["API Response"] --> B1["PGlite Cache"]
        B1 --> C1["UI reads from cache"]
    end

    subgraph Phase2["Phase 2: Offline Write"]
        A2["User action"] --> B2["Write to PGlite"]
        B2 --> C2["Sync queue"]
        C2 --> D2["Server when online"]
    end

    subgraph Phase3["Phase 3: Full Local-First"]
        A3["PGlite = primary"]
        B3["ElectricSQL sync"]
        C3["Server = backup"]
        A3 <--> B3
        B3 <--> C3
    end

    Phase1 --> Phase2
    Phase2 --> Phase3

    style A1 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style B1 fill:#336791,stroke:#fff,color:#fff
    style C1 fill:#e94560,stroke:#fff,color:#fff
    style A2 fill:#e94560,stroke:#fff,color:#fff
    style B2 fill:#336791,stroke:#fff,color:#fff
    style C2 fill:#ff9800,stroke:#fff,color:#fff
    style D2 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
    style A3 fill:#336791,stroke:#fff,color:#fff
    style B3 fill:#4CAF50,stroke:#fff,color:#fff
    style C3 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50

3-phase migration: cache layer → offline write → full Local-First

Phase 1 — PGlite as Cache Layer

// Instead of calling API on every render, cache in PGlite
async function fetchAndCache(endpoint: string) {
  const db = await getDB();

  // Read cache first (instant)
  const cached = await db.query(
    'SELECT data, cached_at FROM api_cache WHERE endpoint = $1',
    [endpoint]
  );

  if (cached.rows.length > 0) {
    const age = Date.now() - new Date(cached.rows[0].cached_at).getTime();
    if (age < 5 * 60 * 1000) { // 5-minute cache
      return JSON.parse(cached.rows[0].data);
    }
  }

  // Fetch from server on cache miss or expiry
  const response = await fetch(endpoint);
  const data = await response.json();

  await db.query(
    `INSERT INTO api_cache (endpoint, data, cached_at)
     VALUES ($1, $2, NOW())
     ON CONFLICT (endpoint)
     DO UPDATE SET data = $2, cached_at = NOW()`,
    [endpoint, JSON.stringify(data)]
  );

  return data;
}

Phase 2 — Offline Write Queue

// Write locally first, sync to server later
async function createTask(title: string, priority: number) {
  const db = await getDB();
  const id = crypto.randomUUID();

  // Write to PGlite immediately
  await db.query(
    `INSERT INTO tasks (id, title, priority, synced) VALUES ($1, $2, $3, false)`,
    [id, title, priority]
  );

  // Push to sync queue
  await db.query(
    `INSERT INTO sync_queue (entity, entity_id, action, payload)
     VALUES ('tasks', $1, 'INSERT', $2)`,
    [id, JSON.stringify({ id, title, priority })]
  );

  // Try syncing immediately if online
  if (navigator.onLine) {
    await processSyncQueue();
  }
}

// Listen for connectivity restoration
window.addEventListener('online', () => processSyncQueue());

Real-World Performance Comparison

OperationCloud-Only (REST API)Local-First (PGlite)Improvement
Read list of 100 items180-400ms0.5-2ms~200x
Create new record200-600ms1-3ms~150x
Full-text search300-800ms5-15ms~40x
Aggregation query500ms-2s10-50ms~30x
Offline supportNon-functional100% functional-

Benchmark Context

Benchmarks measured on Chrome 126, M2 MacBook Air, with a 10,000-record dataset. Cloud API is a REST endpoint on US-East server, user located in Vietnam (RTT ~180ms). Results will vary by device and location, but the improvement trend is consistent.

When NOT to Use Local-First

Local-First is not a silver bullet. There are cases where cloud-only remains the right choice:

Consider Before Adopting

1. Shared/global data: Company-wide analytics dashboards, real-time reports from multiple sources — it's impractical for each client to hold a full copy.
2. Sensitive data: If compliance requires data NOT to be stored on client devices (HIPAA, PCI-DSS), Local-First needs a complex encryption layer.
3. Write-heavy workloads: IoT systems ingesting millions of events/second — PGlite in a browser is not the place for this.
4. Oversized datasets: If each user needs access to multiple GB of data, downloading everything to the client is impractical — shape-based partial sync is required.

The Local-First Ecosystem in 2026

2019
Ink & Switch publishes the seminal paper "Local-First Software: You Own Your Data", laying the theoretical foundation for the Local-First movement.
2022
ElectricSQL launches — the first sync engine connecting PostgreSQL to local SQLite on the client.
2024
PGlite v0.1 released — the first time full PostgreSQL runs on WebAssembly in the browser, at just 3MB.
2025
PGlite + ElectricSQL sync matures with shape-based sync, live queries, and pgvector extension support. Zero (Rocicorp) ships its production release.
2026
Local-First goes mainstream — PGlite supports 50+ extensions, OPFS backend, TanStack DB integration. Major frameworks (Next.js, Nuxt) begin shipping official local-first plugins.

Conclusion

Local-First Architecture is not a complete replacement for the cloud — it's an evolution in how we build web applications. With PGlite bringing PostgreSQL to the browser at just 3MB, ElectricSQL providing a powerful sync layer, and CRDTs solving conflicts mathematically, developers now have the tools to build apps that respond instantly, work offline, and respect user data ownership.

Start with Phase 1 (cache layer) — just a few dozen lines of PGlite code and you'll see a dramatic speed difference. From there, migrate gradually to full Local-First when ready.

References