Local-First Architecture — When Web Apps Stop Waiting for the Server
Posted on: 4/26/2026 9:12:52 PM
Table of contents
- What is Local-First?
- Why Cloud-Only is Hitting Its Limits
- Local-First Architecture Overview
- PGlite — PostgreSQL Running on WebAssembly
- CRDTs — Resolving Conflicts Without a Server
- Sync Engines — Bridging Local and Cloud
- Integration with Vue.js
- Storage Backends — IndexedDB vs OPFS
- Migration Strategy: Cloud-Only to Local-First
- Real-World Performance Comparison
- When NOT to Use Local-First
- The Local-First Ecosystem in 2026
- Conclusion
- References
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.
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:
| Issue | Cloud-Only | Local-First |
|---|---|---|
| Latency | 100-500ms per request (depends on server distance) | < 1ms (read/write directly on device) |
| Offline | Non-functional when disconnected | Fully functional, syncs later |
| Data Security | Centralized — large attack target | Distributed across devices — reduced risk |
| Server Cost | Scales linearly with request count | Only syncs deltas — significantly less bandwidth |
| Data Ownership | Data lives on provider's cloud | Data 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 Type | Description | Use Case |
|---|---|---|
| G-Counter | Grow-only counter, each node has its own counter | View counts, like counts |
| LWW-Register | Last-Writer-Wins — uses timestamps to resolve | Profile fields, settings |
| OR-Set | Observed-Remove Set — add/remove without conflicts | Tag lists, todo lists |
| RGA | Replicated Growable Array — ordered list | Collaborative text editing |
| Fractional Index | Fractional indexing — insert between two elements | Kanban 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 Engine | Storage | Sync Mechanism | Strength |
|---|---|---|---|
| ElectricSQL | PGlite / SQLite | WAL-based replication stream | Deep PostgreSQL integration, shape-based sync |
| Zero (Rocicorp) | Custom store | Incremental view maintenance | Realtime multi-user, Figma-style collaboration |
| Automerge | Binary format | Operation-based CRDT | Collaborative documents, rich merge semantics |
| Yjs | Shared types | Differential sync | Best rich text editing performance |
| PowerSync | SQLite | Bucket-based sync rules | React 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:
| Backend | Write Speed | Capacity | Browser Support | When to Use |
|---|---|---|---|---|
| In-memory | Fastest | RAM limited | All | Testing, prototyping, ephemeral data |
| IndexedDB | Moderate | Hundreds of MB | All | Default choice, widest compatibility |
| OPFS | Fast (native file I/O) | Multiple GB | Chrome, Edge, Firefox | Data-intensive applications |
| File System | Fastest | Disk limit | Node/Bun/Deno | Server-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
| Operation | Cloud-Only (REST API) | Local-First (PGlite) | Improvement |
|---|---|---|---|
| Read list of 100 items | 180-400ms | 0.5-2ms | ~200x |
| Create new record | 200-600ms | 1-3ms | ~150x |
| Full-text search | 300-800ms | 5-15ms | ~40x |
| Aggregation query | 500ms-2s | 10-50ms | ~30x |
| Offline support | Non-functional | 100% 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
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
Semantic Kernel & Microsoft Agent Framework 1.0 — Building AI Agents With C#
Tauri v2 — Building Ultra-Lightweight Desktop Apps with Vue.js and Rust
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.