
Local First
- 110 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
local-first is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- local-first
- AI & Agent Building
- AI-coding skill
Local First by the numbers
- 110 all-time installs (skills.sh)
- +3 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,040 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill local-firstAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 110 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Local-First
Overview
Local-first is an architecture where the application reads and writes to a local database, with changes syncing to the server in the background. The local database is the source of truth for the UI, providing instant reads, offline support, and optimistic writes by default.
When to use: Collaborative apps needing offline support, latency-sensitive UIs where instant response matters, apps with unreliable network conditions, real-time multiplayer features, mobile apps with intermittent connectivity.
When NOT to use: Simple CRUD apps with reliable connectivity, server-authoritative workflows (payments, inventory), content-heavy sites with minimal interactivity, apps where data freshness from the server is critical on every render.
Quick Reference
| Decision | Options | Key Consideration |
|---|---|---|
| Architecture model | Server-based, local-first, hybrid | Offline needs and latency tolerance drive the choice |
| Read path | Server fetch, local DB read, cache-then-network | Local reads are instant; server reads block on network |
| Write path | Server mutation, optimistic update, local-first write | Local writes never fail; sync handles delivery |
| Sync engine | Electric, Zero, PowerSync, Replicache, LiveStore | Postgres integration vs framework-agnostic |
| Client storage | IndexedDB, OPFS, SQLite WASM, PGlite | Capacity limits, query capability, browser support |
| Conflict resolution | LWW, CRDTs, server-wins, field-level merge | Complexity vs correctness tradeoff |
| Data model | Normalized tables, document store, CRDT documents | Query patterns determine the best model |
| Partial replication | Shapes, subscriptions, query-based sync | Sync only what the client needs |
| Progressive enhancement | Server-first with local cache, full local-first | Start simple, add local-first incrementally |
| CQRS separation | Separate read/write models, unified model | Local-first naturally separates reads from writes |
| Initial sync | Full snapshot, incremental, progressive loading | First-load performance vs completeness |
| Auth integration | Token-based shape filtering, row-level security | Security lives at the sync layer, not the client |
| Schema evolution | Additive migrations, versioned shapes | Local DB schema must evolve without data loss |
| State management | Replace React Query, coexist, hybrid approach | Local-first can replace or complement server state |
| Testing strategy | Mock sync engine, test offline scenarios, seed local DB | Test both online and offline code paths |
Common Mistakes
| Mistake | Correct Approach |
|---|---|
| Going local-first for simple CRUD apps | Use server-based architecture unless offline/latency is a real need |
| Choosing a sync engine before defining data model | Define read/write patterns first, then pick the engine that fits |
| Ignoring conflict resolution until late | Design conflict strategy alongside data model from the start |
| Syncing entire database to every client | Use partial replication (shapes, subscriptions) for relevant data |
| Treating local DB as a cache | Local DB is the source of truth for the UI, not a cache layer |
| Using CRDTs for everything | LWW or server-wins is simpler and sufficient for most fields |
| Skipping progressive enhancement | Start server-first, add local-first for high-value interactions |
| Not planning schema migrations | Local databases need migration strategies just like server DBs |
Delegation
If the electricsql skill is available, delegate ElectricSQL setup, shapes, auth, and write patterns to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s electricsql -a claude-code -yIf the realtime-sync skill is available, delegate WebTransport, pub/sub, and CRDT implementation details to it.If the tanstack-db skill is available, delegate collection setup, live queries, and optimistic mutation patterns to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s tanstack-db -a claude-code -yIf the tanstack-start skill is available, delegate server function proxies and SSR integration to it.Otherwise, recommend: pnpm dlx skills add oakoss/agent-skills -s tanstack-start -a claude-code -y- Architecture review: Use
Planagent to evaluate local-first vs server-based tradeoffs - Sync engine comparison: Use
Exploreagent to research current engine capabilities - Storage benchmarking: Use
Taskagent to test storage options for specific data patterns
References
- Architecture patterns and decision framework
- Sync engine comparison and selection guide
- Client-side storage options and limits
- Conflict resolution strategies
- Offline resilience patterns
- Schema versioning and migration
- Multi-tenant data governance patterns
- Testing strategies for local-first apps
- End-to-end encryption for synced data
- DevTools and debugging utilities
- Server-first to local-first migration guide
When to Go Local-First
Evaluate these four criteria to determine the right architecture model:
| Criteria | Server-Based | Local-First | Hybrid |
|---|---|---|---|
| Offline needs | None | Must work offline | Selective offline support |
| Latency sensitivity | Tolerates round-trip | Needs instant response | Critical paths need instant |
| Collaboration | Single-user or turns | Real-time multi-user | Mix of single and multi-user |
| Data size per client | Minimal client state | Manageable local dataset | Some data fits locally |
| Conflict tolerance | N/A (server is truth) | Can handle merge conflicts | Selective conflict handling |
| Development complexity | Low | High | Medium |
Go local-first when at least two of these are true:
1. Users need to work offline or on unreliable networks 2. UI interactions must feel instant (no loading spinners on common actions) 3. Multiple users edit the same data concurrently 4. The working dataset per client fits in browser storage (typically < 500MB)
Stay server-based when:
- Data freshness from the server is critical on every render
- Business rules require server-authoritative validation (payments, inventory)
- The app is content-heavy with minimal interactivity
- Simple CRUD with reliable connectivity
Architecture Models
Server-Based (Traditional)
All reads and writes go through the server. The client has no persistent local state.
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos');
return response.json();
}
async function createTodo(todo: NewTodo): Promise<Todo> {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
return response.json();
}Local-First
All reads come from a local database. All writes go to the local database first, then sync to the server in the background.
import { createCollection, createTanStackDB } from '@tanstack/db';
import { ElectricProvider } from '@tanstack/db/electric';
const db = createTanStackDB({ collections: { todos } });
const todos = createCollection<Todo>({
id: 'todos',
schema: todoSchema,
sync: {
provider: new ElectricProvider({ url: electricUrl, table: 'todos' }),
},
});
function useTodos() {
return db.useQuery((q) => q.from('todos').where('completed', '=', false));
}
function useCreateTodo() {
return (todo: NewTodo) => {
db.mutate.todos.insert({ id: crypto.randomUUID(), ...todo });
};
}Hybrid
Server-first for most features. Local-first for high-value interactions where latency or offline matters.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function useSettings() {
return useQuery({
queryKey: ['settings'],
queryFn: () => fetch('/api/settings').then((r) => r.json()),
});
}
function useTodos() {
return db.useQuery((q) => q.from('todos').orderBy('createdAt', 'desc'));
}CQRS in Local-First
Local-first architecture naturally implements CQRS (Command Query Responsibility Segregation). Reads and writes follow completely different paths.
Read Path: Write Path:
┌────────┐ ┌────────┐
│ UI │ ← reads from │ UI │ ← user action
└────┬───┘ └────┬───┘
│ │
┌────▼───┐ ┌────▼────────┐
│Local DB│ │ Local Write │ ← always succeeds
└────────┘ └────┬─────────┘
▲ │
│ ┌────▼─────┐
│ │ Sync Layer│ ← background
│ └────┬──────┘
│ │
│ ┌────▼──────┐
│ │ Server │
│ └────┬──────┘
│ │
└─────── sync back ──────────────┘The read path never touches the network. The write path writes locally first, then syncs. The server processes the write and syncs the resolved state back.
Read Path Patterns
Direct Server Fetch
Simplest approach. Every read is a network request.
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/todos')
.then((r) => r.json())
.then(setTodos)
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
return (
<ul>
{todos.map((t) => (
<TodoItem key={t.id} todo={t} />
))}
</ul>
);
}Tradeoff: Simple but shows loading spinners on every navigation. Fails completely offline.
Cache-Then-Network
Show cached data immediately, then update when the network responds.
function TodoList() {
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
staleTime: 30_000,
});
if (isLoading) return <Spinner />;
return (
<ul>
{todos.map((t) => (
<TodoItem key={t.id} todo={t} />
))}
</ul>
);
}Tradeoff: Fast subsequent reads, but first load still blocks. No offline support without persistence plugin.
Local-First Read
Reads always come from the local database. The sync engine keeps it up to date.
function TodoList() {
const todos = db.useQuery((q) =>
q.from('todos').where('completed', '=', false).orderBy('createdAt', 'desc'),
);
return (
<ul>
{todos.map((t) => (
<TodoItem key={t.id} todo={t} />
))}
</ul>
);
}Tradeoff: Always instant, works offline, but requires sync infrastructure. No loading state needed for reads.
Write Path Patterns
Server Mutation
Write goes to the server. UI updates after the server responds.
function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: NewTodo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(todo),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}Tradeoff: Simple and correct, but the UI feels sluggish. Button disables during the request. Fails offline.
Optimistic Update (Rollback on Failure)
Assume the write will succeed. Update the UI immediately. Rollback if the server rejects it.
function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (todo: NewTodo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(todo),
}).then((r) => r.json()),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
{ id: crypto.randomUUID(), ...newTodo, completed: false },
...old,
]);
return { previous };
},
onError: (_err, _todo, context) => {
queryClient.setQueryData(['todos'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}Tradeoff: Feels instant but has complexity around rollback. Still fails offline.
Local-First Write
Write to the local database. It always succeeds. Sync delivers it to the server in the background.
function useCreateTodo() {
return (todo: NewTodo) => {
db.mutate.todos.insert({
id: crypto.randomUUID(),
...todo,
completed: false,
createdAt: new Date().toISOString(),
});
};
}
async function syncTodoWrite(todo: Todo) {
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
}Tradeoff: Always instant, works offline, but the server may reject the write later. Conflict resolution is needed.
Progressive Enhancement Strategy
Start server-first. Add local-first incrementally for high-value interactions.
Step 1: Identify candidates. Audit your app for interactions where latency or offline matters most.
| Interaction | Latency Sensitive | Offline Needed | Local-First Candidate |
|---|---|---|---|
| Todo CRUD | Yes | Yes | Yes |
| User settings | No | No | No |
| Chat messages | Yes | Yes | Yes |
| Payment checkout | No | No | No |
| Document editing | Yes | Yes | Yes |
| Admin dashboard | No | No | No |
Step 2: Add local-first to one feature. Keep everything else server-based.
const todos = createCollection<Todo>({
id: 'todos',
schema: todoSchema,
sync: {
provider: new ElectricProvider({ url: electricUrl, table: 'todos' }),
},
});
// Settings still uses server-based approach
function useSettings() {
return useQuery({
queryKey: ['settings'],
queryFn: fetchSettings,
});
}Step 3: Expand gradually. Move more features to local-first as confidence grows.
Data Model Considerations
Normalized Tables
Best for relational queries. Works well with SQL-based sync engines (ElectricSQL, PowerSync).
CREATE TABLE projects (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
owner_id UUID REFERENCES users(id)
);
CREATE TABLE todos (
id UUID PRIMARY KEY,
project_id UUID REFERENCES projects(id),
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false
);Document Store
Best for offline-friendly blobs. Works well with document-based sync (Replicache, Triplit).
type TodoDocument = {
id: string;
title: string;
completed: boolean;
project: {
id: string;
name: string;
};
tags: string[];
metadata: Record<string, unknown>;
};Hybrid Approach
Normalized tables for relational data. Embedded documents for self-contained entities.
CREATE TABLE todos (
id UUID PRIMARY KEY,
project_id UUID REFERENCES projects(id),
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
metadata JSONB DEFAULT '{}'
);Migration Path: Server-Based to Local-First
Evolve incrementally without rewriting your app.
Phase 1: Add a local cache layer. Use TanStack Query with persistence.
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
persistQueryClient({ queryClient, persister });Phase 2: Introduce a local database for high-value data.
const todos = createCollection<Todo>({
id: 'todos',
schema: todoSchema,
sync: {
provider: new ElectricProvider({ url: electricUrl, table: 'todos' }),
},
});Phase 3: Move writes to local-first for synced collections.
// Before: server mutation
const mutation = useMutation({
mutationFn: (todo: NewTodo) => api.createTodo(todo),
});
// After: local-first write
function createTodo(todo: NewTodo) {
db.mutate.todos.insert({ id: crypto.randomUUID(), ...todo });
}Phase 4: Remove server-fetch code for synced data. The local database is now the source of truth. Server fetches are replaced by sync.
Overview Table
| Feature | IndexedDB | OPFS | SQLite WASM | PGlite |
|---|---|---|---|---|
| Capacity | 50%+ of disk | 50%+ of disk | Depends on backend | Depends on backend |
| Query language | Cursor/index-based | File system API | Full SQL | Full Postgres SQL |
| Browser support | All modern browsers | Chrome, Edge, Firefox | All (via WASM) | All (via WASM) |
| Persistence | Until evicted | Until evicted | IndexedDB or OPFS | IndexedDB or OPFS |
| Thread safety | Multi-tab safe | Sync API: Worker only | Depends on VFS | Single-connection |
| Bundle size | 0 (built-in) | 0 (built-in) | ~500KB-1MB | ~3-5MB |
| Best for | Key-value, simple | SQLite backend | Complex queries | Postgres compatibility |
IndexedDB
Object store built into every modern browser. Stores structured data with indexes for fast lookups. No SQL — queries use cursors and key ranges.
Basic CRUD Pattern
function openDB(name: string, version: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, version);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('todos')) {
const store = db.createObjectStore('todos', { keyPath: 'id' });
store.createIndex('completed', 'completed');
store.createIndex('createdAt', 'createdAt');
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function insertTodo(db: IDBDatabase, todo: Todo): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction('todos', 'readwrite');
tx.objectStore('todos').put(todo);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getTodos(db: IDBDatabase): Promise<Todo[]> {
return new Promise((resolve, reject) => {
const tx = db.transaction('todos', 'readonly');
const request = tx.objectStore('todos').getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function getTodosByIndex(
db: IDBDatabase,
completed: boolean,
): Promise<Todo[]> {
return new Promise((resolve, reject) => {
const tx = db.transaction('todos', 'readonly');
const index = tx.objectStore('todos').index('completed');
const request = index.getAll(IDBKeyRange.only(completed));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function deleteTodo(db: IDBDatabase, id: string): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction('todos', 'readwrite');
tx.objectStore('todos').delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}Structured Clone Gotchas
IndexedDB uses the structured clone algorithm, not JSON serialization. Key differences:
// These types are supported (unlike JSON.stringify)
const supported = {
date: new Date(),
regex: /pattern/g,
blob: new Blob(['data']),
arrayBuffer: new ArrayBuffer(8),
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
};
// These types are NOT supported — will throw DataCloneError
const unsupported = {
// functions: () => {},
// symbols: Symbol('test'),
// dom: document.body,
// errors: new Error('test'),
};
// Classes lose their prototype — store plain objects
class Todo {
constructor(
public id: string,
public title: string,
) {}
format() {
return `[${this.id}] ${this.title}`;
}
}
const todo = new Todo('1', 'Test');
// After round-trip through IndexedDB, todo.format() is gone
// Store as plain object: { id: '1', title: 'Test' }OPFS (Origin Private File System)
File system API designed for high-performance binary storage. Primary use case: backing store for SQLite WASM. Provides synchronous access in Web Workers.
Basic File Operations
async function writeFile(name: string, data: string): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(name, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
async function readFile(name: string): Promise<string> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(name);
const file = await fileHandle.getFile();
return file.text();
}
async function deleteFile(name: string): Promise<void> {
const root = await navigator.storage.getDirectory();
await root.removeEntry(name);
}
async function listFiles(): Promise<string[]> {
const root = await navigator.storage.getDirectory();
const names: string[] = [];
for await (const [name] of root.entries()) {
names.push(name);
}
return names;
}Synchronous Access (Web Worker Only)
The synchronous access handle API is faster and required for SQLite's synchronous I/O model. Only available in Web Workers.
// worker.ts
async function syncFileAccess(): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle('database.sqlite3', {
create: true,
});
const accessHandle = await fileHandle.createSyncAccessHandle();
const encoder = new TextEncoder();
const data = encoder.encode('binary data');
accessHandle.write(data, { at: 0 });
accessHandle.flush();
const buffer = new ArrayBuffer(data.byteLength);
accessHandle.read(buffer, { at: 0 });
accessHandle.close();
}SQLite WASM
Full SQL database running in the browser via WebAssembly. Uses wa-sqlite or sql.js for the engine and OPFS or IndexedDB for persistence.
Setup with wa-sqlite and OPFS
import * as SQLite from 'wa-sqlite';
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs';
import { OPFSCoopSyncVFS } from 'wa-sqlite/src/examples/OPFSCoopSyncVFS.js';
async function createDB(): Promise<{
sqlite3: SQLiteAPI;
db: number;
}> {
const module = await SQLiteESMFactory();
const sqlite3 = SQLite.Factory(module);
const vfs = await OPFSCoopSyncVFS.create('app', module);
sqlite3.vfs_register(vfs, true);
const db = await sqlite3.open_v2('app.db');
await sqlite3.exec(
db,
`
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
`,
);
return { sqlite3, db };
}Query Patterns
async function insertTodo(
sqlite3: SQLiteAPI,
db: number,
todo: { id: string; title: string },
): Promise<void> {
await sqlite3.exec(
db,
`
INSERT INTO todos (id, title) VALUES ('${todo.id}', '${todo.title}')
`,
);
}
async function queryTodos(sqlite3: SQLiteAPI, db: number): Promise<Todo[]> {
const todos: Todo[] = [];
await sqlite3.exec(
db,
`SELECT * FROM todos WHERE completed = 0`,
(row: string[], columns: string[]) => {
const todo = {} as Record<string, string>;
columns.forEach((col, i) => {
todo[col] = row[i];
});
todos.push(todo as unknown as Todo);
},
);
return todos;
}
// Use prepared statements for parameterized queries
async function queryByTitle(
sqlite3: SQLiteAPI,
db: number,
search: string,
): Promise<Todo[]> {
const todos: Todo[] = [];
const str = sqlite3.str_new(
db,
`
SELECT * FROM todos WHERE title LIKE ?
`,
);
const prepared = await sqlite3.prepare_v2(db, sqlite3.str_value(str));
if (prepared) {
sqlite3.bind_text(prepared.stmt, 1, `%${search}%`);
while ((await sqlite3.step(prepared.stmt)) === SQLite.SQLITE_ROW) {
todos.push({
id: sqlite3.column_text(prepared.stmt, 0),
title: sqlite3.column_text(prepared.stmt, 1),
completed: sqlite3.column_int(prepared.stmt, 2) === 1,
} as Todo);
}
sqlite3.finalize(prepared.stmt);
}
sqlite3.str_finish(str);
return todos;
}PGlite
Full Postgres database running in the browser via WASM. Supports Postgres SQL, extensions (pgvector, etc.), and full query compatibility.
Basic Setup
import { PGlite } from '@electric-sql/pglite';
const pg = new PGlite('idb://my-app');
await pg.exec(`
CREATE TABLE IF NOT EXISTS todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
metadata JSONB DEFAULT '{}'
)
`);Query Patterns
async function insertTodo(title: string): Promise<Todo> {
const result = await pg.query<Todo>(
'INSERT INTO todos (title) VALUES ($1) RETURNING *',
[title],
);
return result.rows[0];
}
async function getTodos(): Promise<Todo[]> {
const result = await pg.query<Todo>(
'SELECT * FROM todos WHERE completed = false ORDER BY created_at DESC',
);
return result.rows;
}
async function searchTodos(search: string): Promise<Todo[]> {
const result = await pg.query<Todo>(
"SELECT * FROM todos WHERE title ILIKE '%' || $1 || '%'",
[search],
);
return result.rows;
}
async function updateMetadata(
id: string,
metadata: Record<string, unknown>,
): Promise<void> {
await pg.query('UPDATE todos SET metadata = metadata || $1 WHERE id = $2', [
JSON.stringify(metadata),
id,
]);
}With Extensions
import { PGlite } from '@electric-sql/pglite';
import { vector } from '@electric-sql/pglite/vector';
const pg = new PGlite({
dataDir: 'idb://my-app',
extensions: { vector },
});
await pg.exec('CREATE EXTENSION IF NOT EXISTS vector');
await pg.exec(`
CREATE TABLE IF NOT EXISTS documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
embedding vector(384)
)
`);
// Similarity search
const results = await pg.query(
`SELECT id, content, embedding <=> $1 AS distance
FROM documents
ORDER BY distance
LIMIT 5`,
[JSON.stringify(queryEmbedding)],
);Selection Guide
| If you need... | Use |
|---|---|
| Simple key-value storage, no SQL | IndexedDB |
| High-performance binary file storage | OPFS |
| SQL queries on the client | SQLite WASM |
| Postgres SQL compatibility | PGlite |
| Smallest bundle size | IndexedDB (0 KB) |
| Complex joins and aggregations | SQLite or PGlite |
| Extension support (pgvector, etc.) | PGlite |
| Backing store for sync engines | OPFS + SQLite |
Decision flow:
1. Need SQL? If no, IndexedDB is sufficient. If yes, continue. 2. Need Postgres compatibility? If yes, PGlite. If standard SQL is fine, SQLite WASM. 3. Bundle size constrained? PGlite is 3-5MB. SQLite WASM is ~500KB-1MB. IndexedDB is 0. 4. Using a sync engine? Check what the engine requires — PowerSync needs SQLite, ElectricSQL works with any.
Capacity and Limits
Browser storage quotas vary. Most browsers allow up to 50-80% of available disk space for a single origin.
async function checkStorageQuota(): Promise<{
usage: number;
quota: number;
percentUsed: number;
}> {
const estimate = await navigator.storage.estimate();
return {
usage: estimate.usage ?? 0,
quota: estimate.quota ?? 0,
percentUsed: ((estimate.usage ?? 0) / (estimate.quota ?? 1)) * 100,
};
}Persistent Storage
By default, browsers can evict storage under pressure (low disk space). Request persistent storage to prevent eviction:
async function requestPersistence(): Promise<boolean> {
if (navigator.storage?.persist) {
const granted = await navigator.storage.persist();
console.log(`Persistent storage: ${granted ? 'granted' : 'denied'}`);
return granted;
}
return false;
}Chrome auto-grants persistence for installed PWAs and sites with high engagement. Firefox prompts the user. Safari has limited support.
Eviction Order
When storage pressure occurs and persistence is not granted:
1. Cache API entries (least recently used first) 2. IndexedDB databases (least recently used origin first) 3. OPFS files (same origin-based eviction)
Performance Tips
Batch writes in transactions. Individual writes are expensive; batch them.
// Slow: each insert is its own transaction
for (const todo of todos) {
await insertTodo(db, todo);
}
// Fast: batch all inserts in one transaction
async function batchInsert(db: IDBDatabase, todos: Todo[]): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction('todos', 'readwrite');
const store = tx.objectStore('todos');
for (const todo of todos) {
store.put(todo);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}Avoid reading large blobs on the main thread. Move heavy I/O to a Web Worker.
// main.ts
const worker = new Worker(new URL('./db-worker.ts', import.meta.url), {
type: 'module',
});
worker.postMessage({ type: 'query', sql: 'SELECT * FROM large_table' });
worker.onmessage = (event) => {
const { rows } = event.data;
renderTable(rows);
};Use indexes for frequent queries. Without indexes, IndexedDB scans every record.
Storage Quota Management
StorageManager API
async function getStorageInfo(): Promise<{
usage: number;
quota: number;
percentUsed: number;
persisted: boolean;
}> {
const estimate = await navigator.storage.estimate();
const persisted = (await navigator.storage.persisted?.()) ?? false;
return {
usage: estimate.usage ?? 0,
quota: estimate.quota ?? 0,
percentUsed: ((estimate.usage ?? 0) / (estimate.quota ?? 1)) * 100,
persisted,
};
}
async function requestPersistentStorage(): Promise<boolean> {
if (!navigator.storage?.persist) return false;
return navigator.storage.persist();
}Browser-Specific Limits
| Browser | Quota | Eviction Behavior |
|---|---|---|
| Chrome | 60% of total disk space | LRU by origin, entire origin evicted at once |
| Firefox | 10% of disk or 10 GiB max | LRU by origin, entire origin evicted at once |
| Safari | 60% of disk, 1 GiB soft cap | 7-day ITP cap: evicts after 7 days no interaction |
Safari's Intelligent Tracking Prevention (ITP) proactively evicts all storage for origins that haven't been interacted with in 7 days. This applies to IndexedDB, OPFS, Cache API, and localStorage. PWAs added to the home screen are exempt.
QuotaExceededError Handling
async function safeWrite(
db: IDBDatabase,
storeName: string,
data: unknown,
): Promise<boolean> {
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(data);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return true;
} catch (error) {
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
await evictOldData(db, storeName);
return safeWrite(db, storeName, data);
}
throw error;
}
}Eviction Strategies
Browsers evict entire origins at once based on LRU (least recently used). Within your application, implement your own eviction to stay under quota.
async function evictOldData(
db: IDBDatabase,
storeName: string,
maxAge = 30 * 24 * 60 * 60 * 1000,
): Promise<number> {
const cutoff = Date.now() - maxAge;
let evicted = 0;
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const index = store.index('createdAt');
const range = IDBKeyRange.upperBound(new Date(cutoff).toISOString());
const request = index.openCursor(range);
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
evicted++;
cursor.continue();
}
};
tx.oncomplete = () => resolve(evicted);
tx.onerror = () => reject(tx.error);
});
}Use indexes for frequent queries. Without indexes, IndexedDB scans every record.
// Create indexes during upgrade
request.onupgradeneeded = () => {
const store = db.createObjectStore('todos', { keyPath: 'id' });
store.createIndex('by_project', 'projectId');
store.createIndex('by_status', ['completed', 'createdAt']);
};
// Query using the compound index
const index = store.index('by_status');
const range = IDBKeyRange.bound([false, ''], [false, '\uffff']);
const request = index.openCursor(range, 'prev');Overview Table
| Strategy | Complexity | Data Loss Risk | Best Use Case | Implementation Effort |
|---|---|---|---|---|
| Last-Write-Wins | Low | Medium | Independent fields, settings | Minimal |
| CRDTs | High | None | Collaborative text, shared lists | Significant |
| Server-Wins | Low | Low | Business-critical data | Low |
| Field-Level Merge | Medium | Low | Form data, record editing | Medium |
| Event Sourcing | High | None | Audit trails, undo/redo | Significant |
Last-Write-Wins (LWW)
Simplest conflict resolution. When two clients edit the same record, the last write (by timestamp) wins. Earlier writes are silently discarded.
Wall-Clock Implementation
type LWWRecord<T> = {
value: T;
updatedAt: number;
};
function lwwMerge<T>(local: LWWRecord<T>, remote: LWWRecord<T>): LWWRecord<T> {
if (remote.updatedAt > local.updatedAt) {
return remote;
}
return local;
}
// Usage
const localTodo: LWWRecord<Todo> = {
value: { id: '1', title: 'Buy milk', completed: true },
updatedAt: Date.now(),
};
const remoteTodo: LWWRecord<Todo> = {
value: { id: '1', title: 'Buy oat milk', completed: false },
updatedAt: Date.now() + 100,
};
const resolved = lwwMerge(localTodo, remoteTodo);
// Remote wins because it has a later timestampWall-clock problem: Client clocks can drift. A client with a clock set 5 minutes ahead will always win.
Logical Clock (Lamport Timestamp)
Use a logical clock to avoid wall-clock drift issues. Each operation increments a counter.
type LamportClock = {
counter: number;
nodeId: string;
};
function compareClock(a: LamportClock, b: LamportClock): number {
if (a.counter !== b.counter) {
return a.counter - b.counter;
}
return a.nodeId.localeCompare(b.nodeId);
}
function incrementClock(clock: LamportClock): LamportClock {
return { ...clock, counter: clock.counter + 1 };
}
function mergeClock(local: LamportClock, remote: LamportClock): LamportClock {
return {
counter: Math.max(local.counter, remote.counter) + 1,
nodeId: local.nodeId,
};
}
type LWWValue<T> = {
value: T;
clock: LamportClock;
};
function lwwMergeLogical<T>(
local: LWWValue<T>,
remote: LWWValue<T>,
): LWWValue<T> {
if (compareClock(remote.clock, local.clock) > 0) {
return remote;
}
return local;
}When to use LWW: Settings, preferences, status fields, any field where the latest value is always correct.
When to avoid LWW: Collaborative text editing, counters, lists where concurrent additions should merge.
CRDTs (Conflict-Free Replicated Data Types)
Data structures that mathematically guarantee convergence. Any two replicas that have seen the same set of operations will have the same state, regardless of order.
Counter CRDTs
G-Counter (grow-only): Each node tracks its own count. Total is the sum of all nodes.
type GCounter = Record<string, number>;
function increment(counter: GCounter, nodeId: string): GCounter {
return {
...counter,
[nodeId]: (counter[nodeId] ?? 0) + 1,
};
}
function value(counter: GCounter): number {
return Object.values(counter).reduce((sum, n) => sum + n, 0);
}
function merge(a: GCounter, b: GCounter): GCounter {
const result: GCounter = { ...a };
for (const [node, count] of Object.entries(b)) {
result[node] = Math.max(result[node] ?? 0, count);
}
return result;
}PN-Counter (add and subtract): Two G-Counters — one for increments, one for decrements.
type PNCounter = {
positive: GCounter;
negative: GCounter;
};
function pnIncrement(counter: PNCounter, nodeId: string): PNCounter {
return { ...counter, positive: increment(counter.positive, nodeId) };
}
function pnDecrement(counter: PNCounter, nodeId: string): PNCounter {
return { ...counter, negative: increment(counter.negative, nodeId) };
}
function pnValue(counter: PNCounter): number {
return value(counter.positive) - value(counter.negative);
}
function pnMerge(a: PNCounter, b: PNCounter): PNCounter {
return {
positive: merge(a.positive, b.positive),
negative: merge(a.negative, b.negative),
};
}Yjs (Collaborative Text and Data)
Yjs is a high-performance CRDT implementation for collaborative editing. Supports text, arrays, maps, and XML.
import * as Y from 'yjs';
const doc = new Y.Doc();
// Shared text
const ytext = doc.getText('document');
ytext.insert(0, 'Hello, world!');
// Observe changes
ytext.observe((event) => {
console.log('Text changed:', ytext.toString());
});
// Shared map (like a record)
const ymap = doc.getMap<string>('todo');
ymap.set('title', 'Buy groceries');
ymap.set('completed', 'false');
// Shared array (like a list)
const yarray = doc.getArray<string>('tags');
yarray.push(['urgent']);
yarray.insert(0, ['important']);Sync between peers:
import * as Y from 'yjs';
function syncDocuments(local: Y.Doc, remote: Y.Doc): void {
const localState = Y.encodeStateVector(local);
const remoteState = Y.encodeStateVector(remote);
const localUpdate = Y.encodeStateAsUpdate(local, remoteState);
const remoteUpdate = Y.encodeStateAsUpdate(remote, localState);
Y.applyUpdate(local, remoteUpdate);
Y.applyUpdate(remote, localUpdate);
}With a WebSocket provider:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider(
'wss://your-server.com',
'document-room-id',
doc,
);
provider.on('status', ({ status }: { status: string }) => {
console.log('Connection status:', status);
});
const ytext = doc.getText('content');Automerge
Document-based CRDT library. Tracks changes as a history of operations, enabling merge, undo, and time travel.
import * as Automerge from '@automerge/automerge';
type TodoDoc = {
todos: Array<{ id: string; title: string; completed: boolean }>;
};
let doc = Automerge.init<TodoDoc>();
doc = Automerge.change(doc, (d) => {
d.todos = [];
});
doc = Automerge.change(doc, (d) => {
d.todos.push({ id: '1', title: 'Buy milk', completed: false });
});
// Fork for offline editing
let fork = Automerge.clone(doc);
doc = Automerge.change(doc, (d) => {
d.todos[0].title = 'Buy oat milk';
});
fork = Automerge.change(fork, (d) => {
d.todos[0].completed = true;
});
// Merge: both changes are preserved
doc = Automerge.merge(doc, fork);
// Result: { id: '1', title: 'Buy oat milk', completed: true }When to use CRDTs: Collaborative text editing, shared lists where concurrent additions should merge, counters, any data where losing concurrent edits is unacceptable.
Server-Wins
Client sends writes to the server. The server resolves any conflicts authoritatively. The resolved state syncs back to the client, overwriting local state.
This is the model used by ElectricSQL: reads sync from the server via Shapes, writes go through your API, and the server's state is the final truth.
// Client: write through API, let sync update local state
async function updateTodo(id: string, updates: Partial<Todo>): Promise<void> {
db.mutate.todos.update({
where: { id },
set: updates,
});
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
console.error('Server rejected update, sync will correct local state');
}
}Server-side conflict resolution:
// Server: resolve conflicts with business logic
async function handleTodoUpdate(
id: string,
clientUpdates: Partial<Todo>,
_clientTimestamp: number,
): Promise<Todo> {
const current = await db.query('SELECT * FROM todos WHERE id = $1', [id]);
const resolved: Partial<Todo> = {};
if (clientUpdates.title !== undefined) {
resolved.title = clientUpdates.title;
}
// Server-authoritative: completed status follows business rules
if (clientUpdates.completed !== undefined) {
const canComplete = await validateCompletion(id);
resolved.completed = canComplete
? clientUpdates.completed
: current.completed;
}
const result = await db.query(
'UPDATE todos SET title = COALESCE($1, title), completed = COALESCE($2, completed) WHERE id = $3 RETURNING *',
[resolved.title, resolved.completed, id],
);
return result.rows[0];
}When to use server-wins: Payment processing, inventory management, approval workflows, any data where business rules must be enforced authoritatively.
Field-Level Merge
Instead of resolving conflicts at the record level, merge at individual field granularity. If two clients edit different fields of the same record, both edits are preserved.
type FieldTimestamp = Record<string, number>;
type MergeableRecord<T> = {
value: T;
fieldTimestamps: FieldTimestamp;
};
function fieldMerge<T extends Record<string, unknown>>(
local: MergeableRecord<T>,
remote: MergeableRecord<T>,
): MergeableRecord<T> {
const merged = { ...local.value };
const timestamps = { ...local.fieldTimestamps };
for (const key of Object.keys(remote.value)) {
const remoteTs = remote.fieldTimestamps[key] ?? 0;
const localTs = local.fieldTimestamps[key] ?? 0;
if (remoteTs > localTs) {
(merged as Record<string, unknown>)[key] = remote.value[key];
timestamps[key] = remoteTs;
}
}
return { value: merged as T, fieldTimestamps: timestamps };
}
// Client A edits title at t=100
const clientA: MergeableRecord<Todo> = {
value: { id: '1', title: 'Updated title', completed: false },
fieldTimestamps: { title: 100, completed: 50 },
};
// Client B edits completed at t=110
const clientB: MergeableRecord<Todo> = {
value: { id: '1', title: 'Original title', completed: true },
fieldTimestamps: { title: 50, completed: 110 },
};
const result = fieldMerge(clientA, clientB);
// Result: { title: 'Updated title', completed: true }
// Both edits preserved because they touched different fieldsWhen to use field-level merge: Form data where different users edit different fields, profile updates, settings where fields are independent.
Event Sourcing
Store events (facts about what happened) rather than current state. Derive current state by replaying events. Enables audit trails, undo/redo, and time-travel debugging.
Event Store Pattern
type TodoEvent =
| { type: 'TodoCreated'; id: string; title: string; timestamp: number }
| { type: 'TodoCompleted'; id: string; timestamp: number }
| { type: 'TodoRenamed'; id: string; title: string; timestamp: number }
| { type: 'TodoDeleted'; id: string; timestamp: number };
const eventLog: TodoEvent[] = [];
function appendEvent(event: TodoEvent): void {
eventLog.push(event);
}
function getEventsSince(timestamp: number): TodoEvent[] {
return eventLog.filter((e) => e.timestamp > timestamp);
}Projection Function
Derive current state from events:
type TodoState = Map<string, Todo>;
function projectTodos(events: TodoEvent[]): TodoState {
const state: TodoState = new Map();
for (const event of events) {
const existing = state.get(event.id);
switch (event.type) {
case 'TodoCreated':
state.set(event.id, {
id: event.id,
title: event.title,
completed: false,
createdAt: event.timestamp,
});
break;
case 'TodoCompleted':
if (existing) state.set(event.id, { ...existing, completed: true });
break;
case 'TodoRenamed':
if (existing) state.set(event.id, { ...existing, title: event.title });
break;
case 'TodoDeleted':
state.delete(event.id);
break;
}
}
return state;
}Sync and Undo via Event Exchange
function mergeEventLogs(local: TodoEvent[], remote: TodoEvent[]): TodoEvent[] {
const seen = new Set(local.map((e) => `${e.type}-${e.timestamp}-${e.id}`));
const newEvents = remote.filter(
(e) => !seen.has(`${e.type}-${e.timestamp}-${e.id}`),
);
return [...local, ...newEvents].sort((a, b) => a.timestamp - b.timestamp);
}
function undo(events: TodoEvent[]): TodoState {
return projectTodos(events.slice(0, -1));
}When to use event sourcing: Audit trails (compliance, finance), undo/redo, time-travel debugging, or when change history matters as much as current state.
Choosing a Strategy
| Data Type | Recommended Strategy | Rationale |
|---|---|---|
| User preferences | LWW | Last setting is always correct |
| Status fields | LWW or server-wins | Simple, low conflict surface |
| Form fields | Field-level merge | Different users edit different fields |
| Collaborative text | CRDTs (Yjs, Automerge) | Concurrent edits must merge character-by-character |
| Shared lists | CRDTs (OR-Set) | Concurrent additions should all be preserved |
| Counters (likes, votes) | CRDTs (PN-Counter) | Concurrent increments must not be lost |
| Payment amounts | Server-wins | Business rules must be enforced |
| Inventory quantities | Server-wins | Consistency is more important than availability |
| Document history | Event sourcing | Need audit trail and undo |
Hybrid Approach
Use different strategies for different fields in the same record:
const todoFieldStrategies: Record<string, string> = {
title: 'lww',
description: 'crdt',
completed: 'server-wins',
tags: 'crdt',
priority: 'lww',
assigneeId: 'server-wins',
};
function resolveField<T>(field: string, local: T, remote: T): T {
const strategy = todoFieldStrategies[field] ?? 'lww';
if (strategy === 'server-wins') return remote;
return local;
}Simple fields use LWW. Business-critical fields use server-wins. Collaborative content uses CRDTs.
IndexedDB Inspection
Chrome / Edge
1. Open DevTools (F12) 2. Navigate to Application tab 3. Expand IndexedDB in the left sidebar 4. Click a database to see object stores 5. Click an object store to browse records 6. Right-click a record to edit or delete
Firefox
1. Open DevTools (F12) 2. Navigate to Storage tab 3. Expand Indexed DB in the left sidebar 4. Browse databases, object stores, and records
Programmatic Listing
async function listDatabases(): Promise<IDBDatabaseInfo[]> {
if (indexedDB.databases) {
return indexedDB.databases();
}
return [];
}
async function inspectDatabase(name: string): Promise<{
stores: string[];
version: number;
}> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name);
request.onsuccess = () => {
const db = request.result;
resolve({
stores: Array.from(db.objectStoreNames),
version: db.version,
});
db.close();
};
request.onerror = () => reject(request.error);
});
}OPFS Inspection
OPFS is not visible in standard DevTools storage panels. Use browser extensions or programmatic access.
OPFS Explorer Extension
Available for Chrome and Firefox. Adds an OPFS Explorer panel to DevTools that displays the OPFS file tree, file sizes, and allows downloading files.
Install from: Chrome Web Store or Firefox Add-ons (search "OPFS Explorer").
OPFS Viewer
A standalone web-based tool for inspecting OPFS contents. Useful when extensions are not available.
Programmatic OPFS Inspection
async function listOPFSFiles(
dir?: FileSystemDirectoryHandle,
path = '',
): Promise<{ path: string; kind: string }[]> {
const root = dir ?? (await navigator.storage.getDirectory());
const entries: { path: string; kind: string }[] = [];
for await (const [name, handle] of root.entries()) {
const fullPath = path ? `${path}/${name}` : name;
entries.push({ path: fullPath, kind: handle.kind });
if (handle.kind === 'directory') {
const children = await listOPFSFiles(
handle as FileSystemDirectoryHandle,
fullPath,
);
entries.push(...children);
}
}
return entries;
}Storage Overview Utility
async function debugStorageInfo(): Promise<{
estimate: { usage: number; quota: number; percentUsed: number };
persisted: boolean;
databases: IDBDatabaseInfo[];
opfsFiles: { path: string; kind: string }[];
}> {
const estimate = await navigator.storage.estimate();
const persisted = (await navigator.storage.persisted?.()) ?? false;
let databases: IDBDatabaseInfo[] = [];
if (indexedDB.databases) {
databases = await indexedDB.databases();
}
let opfsFiles: { path: string; kind: string }[] = [];
try {
opfsFiles = await listOPFSFiles();
} catch {
// OPFS not available
}
return {
estimate: {
usage: estimate.usage ?? 0,
quota: estimate.quota ?? 0,
percentUsed: ((estimate.usage ?? 0) / (estimate.quota ?? 1)) * 100,
},
persisted,
databases,
opfsFiles,
};
}Dexie.js Debug Mode
Dexie provides built-in logging for IndexedDB operations.
import Dexie from 'dexie';
Dexie.debug = true;
Dexie.debug = 'dexie';With Dexie.debug = true, all IndexedDB transactions, queries, and mutations are logged to the console with timing information. Set to 'dexie' for verbose output including internal operations.
Disable in production:
if (import.meta.env.DEV) {
Dexie.debug = true;
}Yjs Document State Inspector
import { type Doc as YDoc } from 'yjs';
function inspectYDoc(doc: YDoc): {
clientID: number;
guid: string;
sharedTypes: { name: string; type: string; length: number }[];
stateVector: Map<number, number>;
updateSize: number;
} {
const { encodeStateAsUpdate, encodeStateVector, decodeStateVector } =
require('yjs') as typeof import('yjs');
const update = encodeStateAsUpdate(doc);
const sv = decodeStateVector(encodeStateVector(doc));
const sharedTypes: { name: string; type: string; length: number }[] = [];
doc.share.forEach((type, name) => {
sharedTypes.push({
name,
type: type.constructor.name,
length: type.length,
});
});
return {
clientID: doc.clientID,
guid: doc.guid,
sharedTypes,
stateVector: sv,
updateSize: update.byteLength,
};
}Sync Event Replay
Instrument sync providers to capture and replay synchronization events for debugging.
Sync Event Logger
type SyncDirection = 'send' | 'receive';
interface SyncEvent {
direction: SyncDirection;
timestamp: number;
size: number;
type: string;
data?: Uint8Array;
}
class SyncEventLogger {
private events: SyncEvent[] = [];
private maxEvents: number;
constructor(maxEvents = 1000) {
this.maxEvents = maxEvents;
}
log(direction: SyncDirection, type: string, data?: Uint8Array): void {
if (this.events.length >= this.maxEvents) {
this.events.shift();
}
this.events.push({
direction,
timestamp: performance.now(),
size: data?.byteLength ?? 0,
type,
data: data ? new Uint8Array(data) : undefined,
});
}
getEvents(): readonly SyncEvent[] {
return this.events;
}
getSummary(): {
totalSent: number;
totalReceived: number;
bytesSent: number;
bytesReceived: number;
eventTypes: Record<string, number>;
} {
let totalSent = 0;
let totalReceived = 0;
let bytesSent = 0;
let bytesReceived = 0;
const eventTypes: Record<string, number> = {};
for (const event of this.events) {
if (event.direction === 'send') {
totalSent++;
bytesSent += event.size;
} else {
totalReceived++;
bytesReceived += event.size;
}
eventTypes[event.type] = (eventTypes[event.type] ?? 0) + 1;
}
return { totalSent, totalReceived, bytesSent, bytesReceived, eventTypes };
}
clear(): void {
this.events = [];
}
}Instrumenting a WebSocket Sync Provider
function instrumentWebSocket(
ws: WebSocket,
logger: SyncEventLogger,
): WebSocket {
const originalSend = ws.send.bind(ws);
ws.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
const size =
data instanceof ArrayBuffer
? data.byteLength
: typeof data === 'string'
? data.length
: 0;
logger.log(
'send',
'ws-message',
data instanceof Uint8Array ? data : undefined,
);
originalSend(data);
};
ws.addEventListener('message', (event: MessageEvent) => {
const data = event.data;
logger.log(
'receive',
'ws-message',
data instanceof ArrayBuffer ? new Uint8Array(data) : undefined,
);
});
return ws;
}Web Crypto API Pattern
AES-GCM-256 provides authenticated encryption with built-in tamper detection. The Web Crypto API is available in all modern browsers and requires no dependencies.
Key Generation
async function generateEncryptionKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
]);
}
async function exportKey(key: CryptoKey): Promise<ArrayBuffer> {
return crypto.subtle.exportKey('raw', key);
}
async function importKey(raw: ArrayBuffer): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, [
'encrypt',
'decrypt',
]);
}Encrypt and Decrypt
async function encrypt(
key: CryptoKey,
plaintext: Uint8Array,
): Promise<{ iv: Uint8Array; ciphertext: ArrayBuffer }> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext,
);
return { iv, ciphertext };
}
async function decrypt(
key: CryptoKey,
iv: Uint8Array,
ciphertext: ArrayBuffer,
): Promise<ArrayBuffer> {
return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
}Multi-User Key Management
Each document gets a symmetric key for content encryption. Asymmetric key exchange distributes the document key to authorized users.
Per-Document Key with Asymmetric Exchange
async function generateKeyPair(): Promise<CryptoKeyPair> {
return crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['wrapKey', 'unwrapKey'],
);
}
async function wrapDocumentKey(
documentKey: CryptoKey,
recipientPublicKey: CryptoKey,
): Promise<ArrayBuffer> {
return crypto.subtle.wrapKey('raw', documentKey, recipientPublicKey, {
name: 'RSA-OAEP',
});
}
async function unwrapDocumentKey(
wrappedKey: ArrayBuffer,
privateKey: CryptoKey,
): Promise<CryptoKey> {
return crypto.subtle.unwrapKey(
'raw',
wrappedKey,
privateKey,
{ name: 'RSA-OAEP' },
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt'],
);
}Key Distribution Flow
1. Creator generates AES-GCM-256 document key
2. Creator wraps document key with each collaborator's RSA public key
3. Wrapped keys stored alongside document metadata (unencrypted envelope)
4. Each collaborator unwraps with their RSA private key
5. On member removal: rotate document key + re-wrap for remaining membersEncryption Approaches
Symmetric + Asymmetric Hybrid
The standard web approach. AES-GCM for content, RSA-OAEP or ECDH for key exchange. Works with Web Crypto API directly. Best for: client-server architectures where a server can broker key exchange.
MLS (RFC 9420 — Messaging Layer Security)
Group key agreement protocol designed for large groups. Provides forward secrecy and post-compromise security. Members share a group secret via a ratchet tree — adding/removing members is O(log n). Best for: large collaborative groups where membership changes frequently.
Keyhive (P2P)
Capability-based key management for peer-to-peer systems. No central server required. Keys are distributed through a directed acyclic graph of capabilities. Built for local-first architectures where devices sync directly. Best for: fully decentralized apps without a central authority.
E2EE with CRDTs
Encrypting CRDT updates requires care — the CRDT layer must see plaintext to merge, but the transport layer must only see ciphertext.
SecSync (Yjs + XChaCha20-Poly1305)
import { createSyncEngine } from 'secsync';
const engine = createSyncEngine({
documentId: 'doc-123',
signatureKeyPair: userSignatureKeys,
websocketEndpoint: 'wss://sync.example.com',
sodium,
getDocumentKey: async () => documentSymmetricKey,
getYDoc: () => yDoc,
});SecSync encrypts each Yjs update before sending to the server. The server stores opaque ciphertext and routes it to peers. Server never sees plaintext content.
Jazz (CRDT + Crypto Permissions)
Jazz combines CRDTs with a built-in permission system. Each CoValue (collaborative value) has an owner group with role-based access. Encryption keys are derived from group membership. The framework handles key rotation on membership changes automatically.
@localfirst/crdx
Provides encrypted CRDT synchronization with a hash graph structure. Each change is signed by its author and encrypted for the group. Supports fine-grained permissions at the field level.
Architecture Pattern
┌─────────────┐ encrypt ┌─────────────┐ network ┌─────────────┐
│ CRDT Layer │ ──────────────▶ │ Encrypted │ ──────────────▶ │ Server │
│ (plaintext) │ │ Updates │ │ (ciphertext)│
└─────────────┘ decrypt └─────────────┘ network └─────────────┘
▲ ◀────────────── ▲ ◀────────────── │
│ │ │
└─── merge locally ───────────────┘ │
│
routes to other peers ───────────┘Metadata (document ID, sender ID, timestamps) stays unencrypted so the server can route updates without decrypting content.
Encrypt-Before-Send Pattern
async function encryptUpdate(
key: CryptoKey,
update: Uint8Array,
): Promise<Uint8Array> {
const { iv, ciphertext } = await encrypt(key, update);
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return combined;
}
async function decryptUpdate(
key: CryptoKey,
payload: Uint8Array,
): Promise<Uint8Array> {
const iv = payload.slice(0, 12);
const ciphertext = payload.slice(12);
const plaintext = await decrypt(key, iv, ciphertext.buffer);
return new Uint8Array(plaintext);
}Libraries
| Library | Type | Size | Notes |
|---|---|---|---|
| Web Crypto API | Built-in | 0 KB | AES-GCM, RSA-OAEP, ECDH. All browsers. |
| libsodium.js | WASM | ~180 KB | XChaCha20-Poly1305, Ed25519, X25519 |
| sodium-plus | Wrapper | ~10 KB | Ergonomic API over libsodium.js |
| vodozemac | Rust → WASM | ~150 KB | Olm/Megolm (Matrix protocol encryption) |
| SecSync | Framework | Varies | Yjs + XChaCha20-Poly1305 integration |
| Jazz | Framework | Varies | Built-in CRDT encryption + permissions |
| @localfirst/crdx | Framework | Varies | Hash graph CRDT with per-change encryption |
When to Use What
- Simple document encryption: Web Crypto API (zero dependencies)
- Group messaging with forward secrecy: libsodium.js + custom MLS implementation
- Yjs-based collaboration: SecSync
- Full-stack local-first with permissions: Jazz
- P2P without central authority: @localfirst/crdx with Keyhive
Incremental Adoption Path
Migrate one component or route at a time. The local-first stack coexists with existing server-first code — no big-bang rewrite required.
Phase 1: Install TanStack DB alongside TanStack Query
Phase 2: Create query collections (reuse existing queryFn)
Phase 3: Replace useQuery with useLiveQuery
Phase 4: Swap query collections for Electric collectionsStep-by-Step Migration
Step 1: Install TanStack DB
pnpm add @tanstack/db @tanstack/db-electricStep 2: Create a Query Collection
Reuse the existing queryFn from TanStack Query. No backend changes needed.
import { createQueryCollection } from '@tanstack/db';
const todosCollection = createQueryCollection({
id: 'todos',
queryFn: async () => {
const response = await fetch('/api/todos');
return response.json() as Promise<Todo[]>;
},
getId: (todo) => todo.id,
schema: todoSchema,
});Step 3: Replace useQuery with useLiveQuery
// Before: server-first
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
});
// After: local-first (same data, instant reads)
const todos = useLiveQuery((q) =>
q.from({ todosCollection }).where('@completed', '=', false).toArray(),
);Component JSX stays identical. Only the data-fetching hook changes.
Step 4: Swap to Electric Collection
When ready for real-time sync, replace the query collection with an Electric collection. No component changes required.
import { createElectricCollection } from '@tanstack/db-electric';
const todosCollection = createElectricCollection({
id: 'todos',
electricUrl: 'http://localhost:3000/v1/shape',
electricParams: { table: 'todos' },
getId: (todo) => todo.id,
schema: todoSchema,
});The useLiveQuery calls remain untouched.
Database Preparation for Electric
ElectricSQL requires logical replication on Postgres.
Postgres Configuration
ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_replication_slots = 10;
ALTER SYSTEM SET max_wal_senders = 10;Restart Postgres after changing wal_level.
Replication User
CREATE ROLE electric_user WITH LOGIN PASSWORD 'electric_pass' REPLICATION;
GRANT USAGE ON SCHEMA public TO electric_user;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO electric_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO electric_user;Docker Compose Example
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
command: >
postgres
-c wal_level=logical
-c max_replication_slots=10
-c max_wal_senders=10
ports:
- '5432:5432'
electric:
image: electricsql/electric
environment:
DATABASE_URL: postgresql://electric_user:electric_pass@postgres:5432/app
ports:
- '3000:3000'
depends_on:
- postgresCoexistence Patterns
Query collections and Electric collections can coexist in the same application. Use query collections for server-fetched data that doesn't need real-time sync, and Electric collections for locally-synced data.
Cross-Collection Joins
const queryTodos = createQueryCollection({
id: 'todos-query',
queryFn: fetchTodos,
getId: (t) => t.id,
schema: todoSchema,
});
const electricUsers = createElectricCollection({
id: 'users-electric',
electricUrl: 'http://localhost:3000/v1/shape',
electricParams: { table: 'users' },
getId: (u) => u.id,
schema: userSchema,
});
const todosWithUsers = useLiveQuery((q) =>
q
.from({ queryTodos })
.join({ electricUsers }, '@userId', '=', '@id')
.select('@queryTodos.*', '@electricUsers.name')
.toArray(),
);Feature Flagging
Toggle between server-first and local-first per collection.
type SyncMode = 'query' | 'electric';
function createTodoCollection(mode: SyncMode) {
if (mode === 'electric') {
return createElectricCollection({
id: 'todos',
electricUrl: 'http://localhost:3000/v1/shape',
electricParams: { table: 'todos' },
getId: (t) => t.id,
schema: todoSchema,
});
}
return createQueryCollection({
id: 'todos',
queryFn: fetchTodos,
getId: (t) => t.id,
schema: todoSchema,
});
}
const todosCollection = createTodoCollection(
featureFlags.localFirst ? 'electric' : 'query',
);REST to Shape Mapping
| REST Pattern | Electric Shape Equivalent |
|---|---|
GET /todos | { table: 'todos' } |
GET /todos?completed=false | { table: 'todos', where: 'completed = false' } |
GET /todos?fields=id,title | { table: 'todos', columns: ['id', 'title'] } |
GET /todos?userId=123 | { table: 'todos', where: 'user_id = 123' } |
GET /todos?limit=50&offset=0 | Shapes sync all matching rows (paginate on the client) |
GET /todos?sort=created_at | Sort on the client after sync |
Rollback Strategy
Swap createElectricCollection back to createQueryCollection. Component code using useLiveQuery does not change.
// Rollback: replace Electric collection with query collection
const todosCollection = createQueryCollection({
id: 'todos',
queryFn: fetchTodos,
getId: (t) => t.id,
schema: todoSchema,
});No component-level changes are needed because useLiveQuery works with both collection types.
Testing During Migration
PGlite for Unit Tests
import { PGlite } from '@electric-sql/pglite';
async function createTestDB(): Promise<PGlite> {
const pg = new PGlite();
await pg.exec(`
CREATE TABLE todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false
)
`);
return pg;
}Dual-Write Verification
During migration, write to both the old server path and the new local-first path. Compare results to verify consistency.
async function dualWriteVerify(todo: Todo): Promise<{
match: boolean;
server: Todo;
local: Todo;
}> {
const [serverResult, localResult] = await Promise.all([
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(todo),
headers: { 'Content-Type': 'application/json' },
}).then((r) => r.json() as Promise<Todo>),
todosCollection.insert(todo),
]);
return {
match: serverResult.id === localResult.id,
server: serverResult,
local: localResult,
};
}A/B Testing
Route a percentage of users to the local-first path and compare performance metrics.
function getCollectionForUser(userId: string): typeof todosCollection {
const bucket = hashString(userId) % 100;
const useLocalFirst = bucket < 10;
return createTodoCollection(useLocalFirst ? 'electric' : 'query');
}Performance Comparison
| Metric | Server-First | Local-First | Improvement |
|---|---|---|---|
| Read latency | 100-500ms | <1ms | 100-500x |
| Write latency | 100-500ms | <1ms (local) | 100-500x |
| Offline reads | Fails | Works | N/A |
| Offline writes | Fails | Queues locally | N/A |
| Initial load | Fast | Slower (sync) | Tradeoff |
| Data freshness | Real-time | Near real-time | ~50-200ms lag |
| Bundle size increase | 0 | 50-200 KB | Tradeoff |
| Server load | Higher | Lower | Fewer requests |
Workspace/Team Scoping
Each tenant gets isolated shapes with per-tenant where clauses. The server injects tenant context so clients never request cross-tenant data directly.
import { ShapeStream, Shape } from '@electric-sql/client';
type TenantConfig = {
tenantId: string;
apiBase: string;
};
function createTenantShape<T extends Record<string, unknown>>(
table: string,
config: TenantConfig,
additionalWhere?: string,
): ShapeStream<T> {
const where = additionalWhere
? `tenant_id = '${config.tenantId}' AND ${additionalWhere}`
: `tenant_id = '${config.tenantId}'`;
return new ShapeStream<T>({
url: `${config.apiBase}/v1/shape`,
params: { table, where },
});
}Switching Workspaces
Tear down old shapes before starting new ones to prevent data from leaking across tenants in memory.
type ActiveShapes = Map<string, ShapeStream>;
class WorkspaceManager {
private shapes: ActiveShapes = new Map();
private currentTenantId: string | null = null;
async switchWorkspace(newTenantId: string, apiBase: string): Promise<void> {
await this.teardown();
this.currentTenantId = newTenantId;
const config: TenantConfig = { tenantId: newTenantId, apiBase };
this.shapes.set('tasks', createTenantShape('tasks', config));
this.shapes.set(
'documents',
createTenantShape('documents', config, `archived = false`),
);
}
private async teardown(): Promise<void> {
for (const [key, stream] of this.shapes) {
stream.unsubscribeAll();
this.shapes.delete(key);
}
this.currentTenantId = null;
}
}Shape-Per-Tenant Pattern
The server-side proxy injects tenant scoping. Clients never construct where clauses containing tenant IDs directly.
import express from 'express';
const app = express();
const ELECTRIC_URL = process.env.ELECTRIC_URL ?? 'http://localhost:3000';
app.get('/api/shapes/:table', authenticateUser, async (req, res) => {
const tenantId = req.user.tenantId;
const table = req.params.table;
const url = new URL(`${ELECTRIC_URL}/v1/shape`);
url.searchParams.set('table', table);
url.searchParams.set('where', `tenant_id = $1`);
url.searchParams.set('params[1]', tenantId);
for (const param of ['offset', 'handle', 'live'] as const) {
const value = req.query[param];
if (typeof value === 'string') url.searchParams.set(param, value);
}
const response = await fetch(url.toString());
res.status(response.status);
for (const [key, value] of response.headers.entries()) {
res.setHeader(key, value);
}
res.send(await response.text());
});Schema Design
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
title TEXT NOT NULL,
assigned_to UUID,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_tasks_tenant ON tasks(tenant_id);
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id = current_setting('app.tenant_id')::UUID);Data Access Revocation
When a user is removed from a team, the server stops including their data in shapes. The client must clean up stale tenant data.
async function handleRevocation(
revokedTenantId: string,
workspaceManager: WorkspaceManager,
currentTenantId: string | null,
): Promise<void> {
await clearTenantData(revokedTenantId);
if (currentTenantId === revokedTenantId) {
await workspaceManager.switchWorkspace('', '');
window.location.href = '/workspace-selector';
}
}
async function checkMembership(apiBase: string): Promise<string[]> {
const response = await fetch(`${apiBase}/api/memberships`);
const memberships: Array<{ tenantId: string }> = await response.json();
return memberships.map((m) => m.tenantId);
}
async function pruneRevokedTenants(apiBase: string): Promise<void> {
const activeTenants = await checkMembership(apiBase);
const localTenants = await getLocalTenantIds();
for (const localTenantId of localTenants) {
if (!activeTenants.includes(localTenantId)) {
await clearTenantData(localTenantId);
}
}
}Local Data Cleanup
Clear IndexedDB and OPFS data when a user loses access to a tenant or logs out.
async function clearTenantData(tenantId: string): Promise<void> {
const dbName = `tenant_${tenantId}`;
const deleteRequest = indexedDB.deleteDatabase(dbName);
await new Promise<void>((resolve, reject) => {
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => reject(deleteRequest.error);
deleteRequest.onblocked = () => resolve();
});
}
async function clearAllLocalData(): Promise<void> {
const databases = await indexedDB.databases();
for (const db of databases) {
if (db.name?.startsWith('tenant_')) {
indexedDB.deleteDatabase(db.name);
}
}
const root = await navigator.storage.getDirectory();
for await (const [name] of root.entries()) {
if (name.startsWith('tenant_')) {
await root.removeEntry(name, { recursive: true });
}
}
}
async function getLocalTenantIds(): Promise<string[]> {
const databases = await indexedDB.databases();
return databases
.map((db) => db.name)
.filter((name): name is string => name?.startsWith('tenant_') ?? false)
.map((name) => name.replace('tenant_', ''));
}PII and Sensitive Data
Data Expiration (TTL on Local Records)
type TTLRecord<T> = T & {
_expiresAt: number;
};
function withTTL<T>(record: T, ttlMs: number): TTLRecord<T> {
return { ...record, _expiresAt: Date.now() + ttlMs };
}
async function purgeExpired(
db: IDBDatabase,
storeName: string,
): Promise<number> {
const tx = db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const index = store.index('_expiresAt');
const range = IDBKeyRange.upperBound(Date.now());
let purged = 0;
const request = index.openCursor(range);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
purged++;
cursor.continue();
} else {
resolve(purged);
}
};
request.onerror = () => reject(request.error);
});
}
// Run on app startup and periodically
const PURGE_INTERVAL_MS = 5 * 60 * 1000;
setInterval(() => purgeExpired(db, 'sensitive_records'), PURGE_INTERVAL_MS);Encrypting Sensitive Fields at Rest
async function encryptField(
key: CryptoKey,
plaintext: string,
): Promise<string> {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoder.encode(plaintext),
);
const combined = new Uint8Array(
iv.length + new Uint8Array(ciphertext).length,
);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return btoa(String.fromCharCode(...combined));
}
async function decryptField(key: CryptoKey, encoded: string): Promise<string> {
const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext,
);
return new TextDecoder().decode(plaintext);
}Clearing Data on Session Expiry
async function onSessionExpired(): Promise<void> {
await clearAllLocalData();
sessionStorage.clear();
localStorage.removeItem('auth_token');
window.location.href = '/login';
}Role-Based Sync
Different shapes for different roles. Admin sees all team data, members see only their own.
type UserRole = 'admin' | 'manager' | 'member';
type RoleShapeConfig = {
table: string;
where?: string;
};
function getShapesForRole(
role: UserRole,
tenantId: string,
userId: string,
): RoleShapeConfig[] {
const base = [
{ table: 'projects', where: `tenant_id = '${tenantId}'` },
{ table: 'labels', where: `tenant_id = '${tenantId}'` },
];
switch (role) {
case 'admin':
return [
...base,
{ table: 'tasks', where: `tenant_id = '${tenantId}'` },
{ table: 'members', where: `tenant_id = '${tenantId}'` },
{ table: 'audit_log', where: `tenant_id = '${tenantId}'` },
];
case 'manager':
return [
...base,
{ table: 'tasks', where: `tenant_id = '${tenantId}'` },
{ table: 'members', where: `tenant_id = '${tenantId}'` },
];
case 'member':
return [
...base,
{
table: 'tasks',
where: `tenant_id = '${tenantId}' AND assigned_to = '${userId}'`,
},
];
}
}When a user's role changes, tear down existing shapes and reinitialize with getShapesForRole using the new role.
Shared vs Private Data
Some collections are shared across all users (reference data), while others are scoped per-user or per-tenant.
type ShapeCategory = 'public' | 'tenant' | 'private';
type ShapeDefinition = {
table: string;
category: ShapeCategory;
buildWhere: (ctx: { tenantId: string; userId: string }) => string | undefined;
};
const SHAPE_DEFINITIONS: ShapeDefinition[] = [
{ table: 'countries', category: 'public', buildWhere: () => undefined },
{ table: 'plan_features', category: 'public', buildWhere: () => undefined },
{
table: 'projects',
category: 'tenant',
buildWhere: (ctx) => `tenant_id = '${ctx.tenantId}'`,
},
{
table: 'user_preferences',
category: 'private',
buildWhere: (ctx) => `user_id = '${ctx.userId}'`,
},
{
table: 'drafts',
category: 'private',
buildWhere: (ctx) =>
`tenant_id = '${ctx.tenantId}' AND user_id = '${ctx.userId}'`,
},
];
function initializeShapes(
definitions: ShapeDefinition[],
ctx: { tenantId: string; userId: string },
apiBase: string,
): Map<string, ShapeStream> {
const shapes = new Map<string, ShapeStream>();
for (const def of definitions) {
const where = def.buildWhere(ctx);
const params: Record<string, string> = { table: def.table };
if (where) params.where = where;
shapes.set(
def.table,
new ShapeStream({ url: `${apiBase}/v1/shape`, params }),
);
}
return shapes;
}Audit Trail
Track who changed what locally and sync audit events to the server to maintain attribution.
type AuditEvent = {
id: string;
tenantId: string;
userId: string;
action: 'create' | 'update' | 'delete';
table: string;
recordId: string;
changes: Record<string, { from: unknown; to: unknown }>;
timestamp: string;
synced: boolean;
};
async function recordAuditEvent(
db: IDBDatabase,
event: Omit<AuditEvent, 'id' | 'synced'>,
): Promise<void> {
const tx = db.transaction('audit_events', 'readwrite');
const store = tx.objectStore('audit_events');
store.add({ ...event, id: crypto.randomUUID(), synced: false });
}
async function flushAuditEvents(
db: IDBDatabase,
apiBase: string,
): Promise<void> {
const tx = db.transaction('audit_events', 'readonly');
const index = tx.objectStore('audit_events').index('synced');
const unsynced: AuditEvent[] = await new Promise((resolve, reject) => {
const req = index.getAll(false);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (unsynced.length === 0) return;
const response = await fetch(`${apiBase}/api/audit-events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(unsynced),
});
if (response.ok) {
const writeTx = db.transaction('audit_events', 'readwrite');
const store = writeTx.objectStore('audit_events');
for (const event of unsynced) {
store.put({ ...event, synced: true });
}
}
}Data Residency
Configure Electric instances per region to ensure local data respects geographic constraints.
type Region = 'us-east' | 'eu-west' | 'ap-southeast';
const REGIONAL_ENDPOINTS: Record<Region, string> = {
'us-east': 'https://electric-us.example.com',
'eu-west': 'https://electric-eu.example.com',
'ap-southeast': 'https://electric-ap.example.com',
};
async function initializeRegionalSync(
tenantId: string,
tenantRegion: Region,
): Promise<ShapeStream> {
const endpoint = REGIONAL_ENDPOINTS[tenantRegion];
return new ShapeStream({
url: `${endpoint}/v1/shape`,
params: {
table: 'tasks',
where: `tenant_id = '${tenantId}'`,
},
});
}Network Detection
navigator.onLine only detects whether the device has a network interface -- it does not verify actual internet connectivity. A machine connected to a router with no upstream link reports true.
Fetch Probe for Real Detection
async function checkConnectivity(url = '/api/health'): Promise<boolean> {
try {
const response = await fetch(url, {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}Online/Offline Event Listeners
type ConnectionStatus = 'online' | 'offline' | 'checking';
function createConnectionMonitor(
onStatusChange: (status: ConnectionStatus) => void,
) {
let currentStatus: ConnectionStatus = navigator.onLine ? 'online' : 'offline';
async function verify() {
onStatusChange('checking');
const isOnline = await checkConnectivity();
currentStatus = isOnline ? 'online' : 'offline';
onStatusChange(currentStatus);
}
window.addEventListener('online', verify);
window.addEventListener('offline', () => {
currentStatus = 'offline';
onStatusChange('offline');
});
return { getStatus: () => currentStatus };
}Exponential Backoff Reconnection
function createReconnector(onReconnect: () => void) {
let attempt = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
async function tryReconnect() {
const isOnline = await checkConnectivity();
if (isOnline) {
attempt = 0;
onReconnect();
return;
}
attempt++;
const base = Math.min(1000 * 2 ** attempt, 30000);
timer = setTimeout(tryReconnect, base + Math.random() * base * 0.5);
}
return {
start: () => tryReconnect(),
stop: () => {
if (timer) clearTimeout(timer);
},
};
}Write Queue Architecture
Queue Entry Structure
type QueueEntry = {
id: string;
operation: 'create' | 'update' | 'delete';
table: string;
payload: Record<string, unknown>;
idempotencyKey: string;
timestamp: number;
retryCount: number;
maxRetries: number;
status: 'pending' | 'in-flight' | 'failed' | 'dead';
};IndexedDB Persistence Layer
const QUEUE_DB = 'write-queue';
const QUEUE_STORE = 'operations';
const DEAD_LETTER_STORE = 'dead-letters';
function openQueueDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(QUEUE_DB, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(QUEUE_STORE)) {
const store = db.createObjectStore(QUEUE_STORE, { keyPath: 'id' });
store.createIndex('status', 'status');
store.createIndex('timestamp', 'timestamp');
}
if (!db.objectStoreNames.contains(DEAD_LETTER_STORE)) {
db.createObjectStore(DEAD_LETTER_STORE, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function enqueue(
entry: Omit<QueueEntry, 'id' | 'timestamp' | 'retryCount' | 'status'>,
): Promise<string> {
const db = await openQueueDB();
const id = crypto.randomUUID();
const record: QueueEntry = {
...entry,
id,
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
};
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
tx.objectStore(QUEUE_STORE).put(record);
tx.oncomplete = () => resolve(id);
tx.onerror = () => reject(tx.error);
});
}
async function getPendingEntries(): Promise<QueueEntry[]> {
const db = await openQueueDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readonly');
const request = tx.objectStore(QUEUE_STORE).index('timestamp').getAll();
request.onsuccess = () => {
resolve(
(request.result as QueueEntry[]).filter(
(e) => e.status === 'pending' || e.status === 'failed',
),
);
};
request.onerror = () => reject(request.error);
});
}
async function updateEntry(entry: QueueEntry): Promise<void> {
const db = await openQueueDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
tx.objectStore(QUEUE_STORE).put(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function moveToDeadLetter(entry: QueueEntry): Promise<void> {
const db = await openQueueDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([QUEUE_STORE, DEAD_LETTER_STORE], 'readwrite');
tx.objectStore(DEAD_LETTER_STORE).put({
...entry,
status: 'dead',
movedAt: Date.now(),
});
tx.objectStore(QUEUE_STORE).delete(entry.id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function removeEntry(id: string): Promise<void> {
const db = await openQueueDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(QUEUE_STORE, 'readwrite');
tx.objectStore(QUEUE_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}Queue Processing
Draining the Queue on Reconnection
type DrainResult = { succeeded: number; failed: number; deadLettered: number };
async function drainQueue(
sendFn: (entry: QueueEntry) => Promise<Response>,
): Promise<DrainResult> {
const entries = await getPendingEntries();
const result: DrainResult = { succeeded: 0, failed: 0, deadLettered: 0 };
for (const entry of entries) {
await updateEntry({ ...entry, status: 'in-flight' });
try {
const response = await sendFn(entry);
if (response.ok) {
await removeEntry(entry.id);
result.succeeded++;
continue;
}
// 4xx = permanent failure, do not retry
if (response.status >= 400 && response.status < 500) {
await moveToDeadLetter(entry);
result.deadLettered++;
continue;
}
throw new Error(`Server error: ${response.status}`);
} catch {
entry.retryCount++;
if (entry.retryCount >= entry.maxRetries) {
await moveToDeadLetter(entry);
result.deadLettered++;
} else {
await updateEntry({ ...entry, status: 'failed' });
result.failed++;
}
}
}
return result;
}Idempotency Keys
function sendToServer(entry: QueueEntry): Promise<Response> {
return fetch(`/api/${entry.table}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': entry.idempotencyKey,
},
body: JSON.stringify({
operation: entry.operation,
payload: entry.payload,
}),
});
}Retry Strategies
| Strategy | Detail |
|---|---|
| Exponential backoff | min(1000 * 2^attempt, 60000) with 30% jitter |
| Max retries | Cap at 5-10 attempts, then dead-letter |
| Dead letter queue | Store permanently failed ops for manual inspection/retry |
| User notification | Surface dead-lettered items so the user can decide action |
Dead Letter Queue Retry
async function retryDeadLetter(id: string): Promise<void> {
const db = await openQueueDB();
return new Promise((resolve, reject) => {
const tx = db.transaction([QUEUE_STORE, DEAD_LETTER_STORE], 'readwrite');
const getReq = tx.objectStore(DEAD_LETTER_STORE).get(id);
getReq.onsuccess = () => {
const entry = getReq.result;
if (!entry) return;
tx.objectStore(QUEUE_STORE).put({
...entry,
status: 'pending',
retryCount: 0,
});
tx.objectStore(DEAD_LETTER_STORE).delete(id);
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}Persistence Across Restarts
async function resumeQueueOnStartup(
sendFn: (entry: QueueEntry) => Promise<Response>,
onStatusChange: (status: ConnectionStatus) => void,
) {
const pending = await getPendingEntries();
for (const entry of pending.filter((e) => e.status === 'in-flight')) {
await updateEntry({ ...entry, status: 'pending' });
}
if (pending.length === 0) return;
if (await checkConnectivity()) {
onStatusChange('online');
await drainQueue(sendFn);
} else {
onStatusChange('offline');
createReconnector(async () => {
onStatusChange('online');
await drainQueue(sendFn);
}).start();
}
}UI Feedback
Sync Status Indicator
type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
function SyncIndicator({ status }: { status: SyncStatus }) {
const config: Record<SyncStatus, { label: string; color: string }> = {
synced: { label: 'All changes saved', color: 'green' },
syncing: { label: 'Syncing...', color: 'blue' },
offline: { label: 'Offline — changes saved locally', color: 'yellow' },
error: { label: 'Sync error — will retry', color: 'red' },
};
const { label, color } = config[status];
return (
<div role="status" aria-live="polite" style={{ color }}>
{label}
</div>
);
}Stale Data Warning
function StaleDataBanner({ lastSyncedAt }: { lastSyncedAt: number | null }) {
if (!lastSyncedAt) {
return (
<div role="alert">
Data has never been synced. Connect to the internet to load latest data.
</div>
);
}
const staleMinutes = Math.floor((Date.now() - lastSyncedAt) / 60000);
if (staleMinutes < 5) return null;
return (
<div role="alert">
Data last synced {staleMinutes} minutes ago. Some information may be
outdated.
</div>
);
}Background Sync API
Service Worker background sync defers operations until the browser has connectivity. The browser decides when to fire the sync event -- the page does not need to be open.
async function registerBackgroundSync(tag: string): Promise<void> {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
}self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag === 'drain-write-queue') {
event.waitUntil(drainQueue(sendToServer));
}
});| Constraint | Detail |
|---|---|
| Browser support | Chrome, Edge only -- no Firefox or Safari |
| Requires SW | Must have an active Service Worker registration |
| No guaranteed timing | Browser batches sync events, may delay |
| One-shot | Each tag fires once -- re-register for continuous sync |
| Payload via IDB | Cannot pass data to sync event, must read from storage |
Offline-First Read Patterns
Cache-First with Freshness Indicator
type CachedResult<T> = {
data: T;
source: 'cache' | 'network';
cachedAt: number;
isStale: boolean;
};
async function cacheFirstFetch<T>(
key: string,
fetcher: () => Promise<T>,
staleAfterMs = 300000,
): Promise<CachedResult<T>> {
const cached = await getFromCache<T>(key);
if (cached && !navigator.onLine) {
return {
data: cached.data,
source: 'cache',
cachedAt: cached.timestamp,
isStale: true,
};
}
try {
const fresh = await fetcher();
await writeToCache(key, fresh);
return {
data: fresh,
source: 'network',
cachedAt: Date.now(),
isStale: false,
};
} catch {
if (cached) {
return {
data: cached.data,
source: 'cache',
cachedAt: cached.timestamp,
isStale: Date.now() - cached.timestamp > staleAfterMs,
};
}
throw new Error('No cached data available and network request failed');
}
}The Problem
Local-first apps store data on the client. When the server schema changes, clients that have been offline may hold data in an outdated format. Unlike server-side migrations that run once against a centralized database, local-first migrations must handle:
- Clients offline for days or weeks running old schemas
- Sync messages arriving with fields the client does not recognize
- Clients sending data with fields the server no longer expects
- Multiple schema versions active simultaneously across the user base
Migration Strategies
| Strategy | How it works | Tradeoff |
|---|---|---|
| Additive-only | Only add columns/tables, never remove or rename | Simple but schema grows indefinitely |
| Versioned schemas | Explicit version numbers with migration functions | Full control but complex multi-step upgrades |
| Lazy migration | Transform records on read, migrate on next write | Low upfront cost but read performance penalty |
| Dual-write | Write to both old and new format during transition | Safe rollback but doubles write cost during migration |
Additive-Only Pattern
The simplest strategy: only add new columns and tables. Never rename or remove existing ones. Mark deprecated fields with naming conventions.
type TodoV1 = { id: string; title: string; completed: boolean };
type TodoV2 = TodoV1 & { priority: number | null; assigneeId: string | null };
type TodoV3 = TodoV2 & {
dueDate: string | null;
_deprecated_completed: boolean;
status: 'todo' | 'in_progress' | 'done' | null;
};IndexedDB Schema Versioning
IndexedDB has built-in versioning via onupgradeneeded. The callback receives the old version and fires for every version jump -- opening version 3 from version 1 fires upgrades for 2 and 3.
function openAppDB(name: string, targetVersion: number): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, targetVersion);
request.onupgradeneeded = (event) => {
const db = request.result;
const tx = request.transaction!;
const oldVersion = event.oldVersion;
if (oldVersion < 1) {
const todos = db.createObjectStore('todos', { keyPath: 'id' });
todos.createIndex('createdAt', 'createdAt');
}
if (oldVersion < 2) {
const todos = tx.objectStore('todos');
todos.createIndex('priority', 'priority');
todos.createIndex('assigneeId', 'assigneeId');
}
if (oldVersion < 3) {
if (!db.objectStoreNames.contains('comments')) {
db.createObjectStore('comments', { keyPath: 'id' });
}
tx.objectStore('todos').createIndex('status', 'status');
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}Data Migration During Upgrade
function migrateStore(
tx: IDBTransaction,
storeName: string,
transform: (record: Record<string, unknown>) => Record<string, unknown>,
): Promise<void> {
return new Promise((resolve, reject) => {
const store = tx.objectStore(storeName);
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve();
return;
}
cursor.update(transform(cursor.value));
cursor.continue();
};
request.onerror = () => reject(request.error);
});
}SQLite WASM Migrations
Version Table
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
checksum TEXT
);Migration Runner
type SQLiteMigration = { version: number; up: string[]; checksum: string };
const sqliteMigrations: SQLiteMigration[] = [
{
version: 1,
checksum: 'a1b2c3',
up: [
`CREATE TABLE todos (
id TEXT PRIMARY KEY, title TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX idx_todos_created_at ON todos(created_at)`,
],
},
{
version: 2,
checksum: 'd4e5f6',
up: [
`ALTER TABLE todos ADD COLUMN priority INTEGER`,
`ALTER TABLE todos ADD COLUMN assignee_id TEXT`,
],
},
{
version: 3,
checksum: 'g7h8i9',
up: [
`ALTER TABLE todos ADD COLUMN status TEXT DEFAULT 'todo'`,
`UPDATE todos SET status = CASE WHEN completed = 1 THEN 'done' ELSE 'todo' END`,
],
},
];
async function runSQLiteMigrations(
db: {
exec: (sql: string) => void;
selectObjects: (sql: string) => Record<string, unknown>[];
},
migrations: SQLiteMigration[],
): Promise<number> {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')), checksum TEXT
)`);
const applied = db.selectObjects(
'SELECT version, checksum FROM schema_migrations ORDER BY version',
);
const appliedMap = new Map(
applied.map((r) => [r.version as number, r.checksum as string]),
);
let count = 0;
for (const migration of migrations) {
if (appliedMap.has(migration.version)) {
if (appliedMap.get(migration.version) !== migration.checksum) {
throw new Error(`Checksum mismatch for migration ${migration.version}`);
}
continue;
}
db.exec('BEGIN TRANSACTION');
try {
for (const statement of migration.up) db.exec(statement);
db.exec(
`INSERT INTO schema_migrations (version, checksum) VALUES (${migration.version}, '${migration.checksum}')`,
);
db.exec('COMMIT');
count++;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
return count;
}PGlite Migrations
PGlite supports Postgres-style DDL with IF NOT EXISTS guards for safe re-runs.
import { type PGlite } from '@electric-sql/pglite';
const pgliteMigrations = [
{
version: 1,
up: `
CREATE TABLE IF NOT EXISTS todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`,
},
{
version: 2,
up: `
ALTER TABLE todos ADD COLUMN IF NOT EXISTS priority INTEGER;
ALTER TABLE todos ADD COLUMN IF NOT EXISTS assignee_id UUID;
`,
},
{
version: 3,
up: `
DO $$ BEGIN
CREATE TYPE todo_status AS ENUM ('todo', 'in_progress', 'done');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
ALTER TABLE todos ADD COLUMN IF NOT EXISTS status todo_status DEFAULT 'todo';
UPDATE todos SET status = CASE WHEN completed THEN 'done' ELSE 'todo' END WHERE status IS NULL;
`,
},
];
async function runPGliteMigrations(db: PGlite): Promise<number> {
await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`);
const result = await db.query<{ version: number }>(
'SELECT version FROM schema_migrations ORDER BY version',
);
const applied = new Set(result.rows.map((r) => r.version));
let count = 0;
for (const migration of pgliteMigrations) {
if (applied.has(migration.version)) continue;
await db.transaction(async (tx) => {
await tx.exec(migration.up);
await tx.exec(
`INSERT INTO schema_migrations (version) VALUES (${migration.version})`,
);
});
count++;
}
return count;
}Sync-Aware Migrations
When the server schema is ahead of the client, sync messages may contain fields the client does not recognize. Project incoming data to the client schema and store unknown fields separately for round-trip safety.
type SyncRecord = Record<string, unknown>;
type SchemaDefinition = {
knownFields: Set<string>;
requiredFields: Set<string>;
defaults: Record<string, unknown>;
};
function projectWithOverflow(
record: SyncRecord,
schema: SchemaDefinition,
): { projected: SyncRecord; overflow: SyncRecord } {
const projected: SyncRecord = {};
const overflow: SyncRecord = {};
for (const [key, value] of Object.entries(record)) {
if (schema.knownFields.has(key)) projected[key] = value;
else overflow[key] = value;
}
for (const field of schema.requiredFields) {
if (!(field in projected)) projected[field] = schema.defaults[field];
}
return { projected, overflow };
}
function rehydrateForSync(
projected: SyncRecord,
overflow: SyncRecord,
): SyncRecord {
return { ...projected, ...overflow };
}Forward/Backward Compatibility
Version Negotiation
type VersionEnvelope = { schemaVersion: number; data: SyncRecord };
function handleIncoming(
envelope: VersionEnvelope,
clientVersion: number,
schema: SchemaDefinition,
): SyncRecord {
if (envelope.schemaVersion === clientVersion) return envelope.data;
if (envelope.schemaVersion > clientVersion)
return projectWithOverflow(envelope.data, schema).projected;
return applyDefaults(envelope.data, envelope.schemaVersion, clientVersion);
}
function applyDefaults(
record: SyncRecord,
fromVersion: number,
targetVersion: number,
): SyncRecord {
const versionDefaults: Record<number, Record<string, unknown>> = {
2: { priority: null, assigneeId: null },
3: { status: 'todo', dueDate: null },
};
let result = { ...record };
for (let v = fromVersion + 1; v <= targetVersion; v++) {
if (versionDefaults[v]) {
for (const [key, val] of Object.entries(versionDefaults[v])) {
if (!(key in result)) result[key] = val;
}
}
}
return result;
}Data Transformation Between Versions
type TransformFn = (record: SyncRecord) => SyncRecord;
const transforms: Record<string, TransformFn> = {
'1->2': (record) => ({ ...record, priority: null, assigneeId: null }),
'2->3': (record) => {
const tags =
typeof record.tags === 'string'
? JSON.parse(record.tags as string)
: (record.tags ?? []);
return { ...record, tags, status: record.completed ? 'done' : 'todo' };
},
// String field split into multiple fields
'3->4': (record) => {
const fullName = record.assigneeName as string | null;
return {
...record,
assigneeFirstName: fullName?.split(' ')[0] ?? null,
assigneeLastName: fullName?.split(' ').slice(1).join(' ') ?? null,
};
},
};
function migrateRecord(
record: SyncRecord,
fromVersion: number,
toVersion: number,
): SyncRecord {
let current = { ...record };
for (let v = fromVersion; v < toVersion; v++) {
const key = `${v}->${v + 1}`;
const transform = transforms[key];
if (!transform) throw new Error(`No transform found for ${key}`);
current = transform(current);
}
return current;
}Testing Migrations
Migration Verification Checklist
| Check | Method |
|---|---|
| No data loss | Row counts match before and after migration |
| Null handling | Nullable fields default correctly for existing rows |
| Index creation | Query planner uses new indexes |
| Foreign keys | References remain valid after schema changes |
| Round-trip safety | Data survives migrate-up then migrate-down |
| Multi-version jump | Migrating from v1 to v5 directly produces same state |
| Idempotency | Running same migration twice does not error |
Snapshot Test Pattern
async function verifyMigration(
db: {
exec: (sql: string) => void;
selectObjects: (sql: string) => Record<string, unknown>[];
},
seedSql: string[],
migrations: SQLiteMigration[],
fromVersion: number,
toVersion: number,
): Promise<{ passed: boolean; errors: string[] }> {
const errors: string[] = [];
db.exec('BEGIN TRANSACTION');
try {
for (const sql of seedSql) db.exec(sql);
const pending = migrations.filter(
(m) => m.version > fromVersion && m.version <= toVersion,
);
for (const migration of pending) {
for (const statement of migration.up) db.exec(statement);
}
const todos = db.selectObjects('SELECT * FROM todos');
if (todos.length === 0) errors.push('No rows survived migration');
} finally {
db.exec('ROLLBACK');
}
return { passed: errors.length === 0, errors };
}Comparison Table
| Feature | ElectricSQL | Zero | PowerSync | Replicache | LiveStore | Triplit |
|---|---|---|---|---|---|---|
| DB backend | Postgres | Postgres | Postgres, MongoDB | Any | SQLite (client-only) | Built-in (Triplit DB) |
| Sync model | Read-only shapes | Full read+write | Read sync + write API | Push/pull | Event-sourced | Full read+write |
| Conflict resolution | Server-wins (your API) | Built-in | Built-in | Custom (server) | Event replay | Built-in (LWW) |
| Client storage | In-memory / TanStack DB | IndexedDB | SQLite (WASM) | IndexedDB | SQLite (OPFS) | IndexedDB |
| React integration | TanStack DB, hooks | Custom hooks | React hooks | React hooks | Framework-agnostic | React hooks |
| License | Apache 2.0 | ISC | Apache 2.0 | BSL 1.1 | MIT | AGPL / Commercial |
| Maturity | Production | Early | Production | Production | Early | Production |
| Bundle size | Small (shapes client) | Medium | Medium (SQLite WASM) | Small | Medium (SQLite WASM) | Medium |
ElectricSQL
Postgres-native sync engine that streams partial replication (Shapes) from Postgres to clients. The read path syncs data to the client via Shapes. The write path is your own API — Electric does not sync writes back to Postgres.
Key differentiator: Uses Postgres logical replication directly. No separate sync server to manage. Reads sync automatically; writes go through your existing API.
Best fit: Apps with an existing Postgres backend that want to add real-time sync for reads while keeping server-authoritative writes.
import { ShapeStream, Shape } from '@electric-sql/client';
const stream = new ShapeStream({
url: 'http://localhost:3000/v1/shape',
params: {
table: 'todos',
where: 'completed = false',
},
});
const shape = new Shape(stream);
shape.subscribe((data) => {
console.log('Todos:', [...data.values()]);
});With TanStack DB integration:
import { createCollection, createTanStackDB } from '@tanstack/db';
import { ElectricProvider } from '@tanstack/db/electric';
const todos = createCollection<Todo>({
id: 'todos',
schema: todoSchema,
sync: {
provider: new ElectricProvider({
url: 'http://localhost:3000/v1/shape',
table: 'todos',
}),
},
});
const db = createTanStackDB({ collections: { todos } });
// Reads: local, reactive, instant
const activeTodos = db.useQuery((q) =>
q.from('todos').where('completed', '=', false),
);
// Writes: go through your API, sync back via Electric
async function createTodo(todo: NewTodo) {
db.mutate.todos.insert({
id: crypto.randomUUID(),
...todo,
});
await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
}Zero
Full-stack sync engine with both read and write sync. Uses Postgres as the backend and provides a custom query language for the client. Built by the team behind Replicache.
Key differentiator: True read+write sync with built-in conflict resolution. Single system handles both directions.
Best fit: New apps that want full local-first with minimal custom sync code and are comfortable with an early-stage project.
import { Zero } from '@rocicorp/zero';
const z = new Zero({
userID: 'user-123',
server: 'http://localhost:4848',
schema,
kvStore: 'idb',
});
// Reads: reactive queries
const todos = z.query.todo
.where('completed', '=', false)
.orderBy('createdAt', 'desc')
.materialize();
todos.addListener((data) => {
console.log('Todos:', data);
});
// Writes: sync automatically
await z.mutate.todo.insert({
id: crypto.randomUUID(),
title: 'New todo',
completed: false,
});PowerSync
Sync engine focused on Postgres (and MongoDB) with SQLite on the client. Strong focus on mobile (Flutter, React Native) with growing web support.
Key differentiator: SQLite on the client gives full SQL query capability. Strong mobile-first focus with offline-first design.
Best fit: Mobile-first apps (React Native, Flutter) that need reliable offline support with Postgres or MongoDB backends.
import { PowerSyncDatabase, column, Schema, Table } from '@powersync/web';
const todosTable = new Table({
title: column.text,
completed: column.integer,
created_at: column.text,
});
const schema = new Schema({ todos: todosTable });
const db = new PowerSyncDatabase({
schema,
database: { dbFilename: 'app.db' },
});
await db.init();
// Reads: full SQL queries against local SQLite
const todos = await db.getAll(
'SELECT * FROM todos WHERE completed = 0 ORDER BY created_at DESC',
);
// Reactive queries
db.watch('SELECT * FROM todos WHERE completed = 0', [], {
onResult: (results) => console.log('Todos:', results.rows?._array),
});
// Writes: local SQLite + upload queue
await db.execute('INSERT INTO todos (id, title, completed) VALUES (?, ?, ?)', [
crypto.randomUUID(),
'New todo',
0,
]);Replicache
Client-side transactional cache that works with any backend. Uses a push/pull model where the client pushes mutations and pulls the latest state from a custom server endpoint.
Key differentiator: Backend-agnostic. Works with any database and any server framework. Proven at scale (used by Linear, Figma-like apps).
Best fit: Teams with existing non-Postgres backends or complex server logic that want local-first reads with server-authoritative conflict resolution.
import { Replicache } from 'replicache';
const rep = new Replicache({
name: 'user-123',
licenseKey: REPLICACHE_LICENSE_KEY,
pushURL: '/api/replicache/push',
pullURL: '/api/replicache/pull',
mutators: {
async createTodo(tx, todo: NewTodo) {
const id = crypto.randomUUID();
await tx.set(`todo/${id}`, { id, ...todo, completed: false });
},
async toggleTodo(tx, { id }: { id: string }) {
const todo = (await tx.get(`todo/${id}`)) as Todo;
await tx.set(`todo/${id}`, { ...todo, completed: !todo.completed });
},
},
});
// Reads: subscribe to local data
rep.subscribe(
async (tx) => {
const todos = await tx.scan({ prefix: 'todo/' }).values().toArray();
return todos as Todo[];
},
(todos) => console.log('Todos:', todos),
);
// Writes: call mutators (local + queued for push)
await rep.mutate.createTodo({ title: 'New todo' });Server push endpoint (simplified):
import type { MutationV1 } from 'replicache';
export async function handlePush(req: Request) {
const push = await req.json();
for (const mutation of push.mutations as MutationV1[]) {
switch (mutation.name) {
case 'createTodo':
await db.insert('todos', mutation.args);
break;
case 'toggleTodo':
await db.update('todos', mutation.args.id, {
completed: db.raw('NOT completed'),
});
break;
}
}
return new Response('OK');
}LiveStore
SQLite-based reactive store that uses event sourcing under the hood. Framework-agnostic with OPFS for persistence. All state is derived from an append-only event log.
Key differentiator: Event-sourced architecture gives full audit trail, undo/redo, and time-travel debugging. Uses SQLite WASM on OPFS for high-performance persistence.
Best fit: Apps that benefit from event sourcing (audit trails, undo/redo) and want a framework-agnostic reactive store.
import { createLiveStore } from '@livestore/livestore';
import { makeSqliteDeps } from '@livestore/wa-sqlite';
const store = await createLiveStore({
deps: makeSqliteDeps(),
schema: {
events: {
todoCreated: { id: 'string', title: 'string' },
todoToggled: { id: 'string' },
},
state: {
todos: {
select: `SELECT * FROM todos WHERE completed = 0`,
},
},
migrations: [
`CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0
)`,
],
},
});
// Reads: reactive queries derived from event log
store.query$.todos.subscribe((todos) => {
console.log('Todos:', todos);
});
// Writes: dispatch events (appended to event log, projected to state)
store.dispatch('todoCreated', {
id: crypto.randomUUID(),
title: 'New todo',
});Triplit
Full-stack database with built-in sync, schema definition, auth, and real-time queries. Can run as cloud-hosted or self-hosted.
Key differentiator: Full-stack DB that handles both client and server storage with built-in auth and real-time sync. Schema-defined with TypeScript.
Best fit: Teams that want an all-in-one solution without stitching together separate database, sync, and auth layers.
import { TriplitClient } from '@triplit/client';
import { schema } from './schema';
const client = new TriplitClient({
schema,
serverUrl: 'http://localhost:6543',
token: AUTH_TOKEN,
});
// Reads: reactive queries
const query = client.query('todos').where('completed', '=', false).build();
client.subscribe(query, (results) => {
console.log('Todos:', [...results.values()]);
});
// React hook
function useTodos() {
const { results } = useQuery(
client,
client.query('todos').where('completed', '=', false),
);
return results ? [...results.values()] : [];
}
// Writes: sync automatically
await client.insert('todos', {
title: 'New todo',
completed: false,
});Schema definition:
import { Schema as S } from '@triplit/client';
export const schema = {
todos: {
schema: S.Schema({
id: S.Id(),
title: S.String(),
completed: S.Boolean({ default: false }),
createdAt: S.Date({ default: S.Default.now() }),
}),
},
};Selection Criteria
Use this decision matrix to narrow your choice:
| If you need... | Consider |
|---|---|
| Postgres read sync + own write API | ElectricSQL |
| Full read+write sync with Postgres | Zero, Triplit |
| Mobile-first with SQLite on client | PowerSync |
| Any backend, proven at scale | Replicache |
| Event sourcing with audit trail | LiveStore |
| All-in-one DB + sync + auth | Triplit |
| TanStack DB integration | ElectricSQL |
| Open source (permissive license) | ElectricSQL, PowerSync, LiveStore |
| Production-proven maturity | ElectricSQL, Replicache, PowerSync |
Decision flow:
1. Do you have Postgres? If yes, start with ElectricSQL (read sync) or Zero (full sync). If no, consider Replicache or Triplit. 2. Do you need write sync? If reads-only, ElectricSQL is simplest. If full sync, evaluate Zero, PowerSync, or Triplit. 3. What client platform? Web-only favors ElectricSQL or Zero. Mobile needs PowerSync or Replicache. 4. Team size? Small teams benefit from all-in-one solutions (Triplit). Larger teams can stitch together components. 5. Maturity requirement? Production-critical apps should lean toward ElectricSQL, Replicache, or PowerSync.