electric-sql
Teaches ElectricSQL, a Postgres-backed local-first sync framework. Covers the Electric architecture where Postgres is the source of truth and data syncs to local SQLite databases on client devices via shape-based partial replication. Includes shape definitions, live queries, offline-first patterns, conflict resolution with rich CRDTs, integration with React and Expo (React Native), deployment patterns, and migration strategies.
Sync Postgres data to local SQLite on every device. Full SQL locally, real-time sync when connected.
## Key Points
- Postgres is the source of truth
- Clients get real SQLite databases (not a mock)
- Sync is shape-based (subscribe to subsets of data)
- Works offline; queues writes for later sync
- Conflict resolution via CRDTs built into the replication layer
- You already use Postgres and want to add local-first capabilities
- Your data model is relational and benefits from SQL
- You need server-side authority (Postgres is the source of truth)
- You want partial sync (shapes) rather than full replication
- Pure peer-to-peer with no server
- Document-centric data (consider Yjs or Automerge)
- Non-Postgres backends
## Quick Example
```
Users → CDN (static assets) → Electric Service → Postgres
```
```
Users (US) → Electric (US) → Postgres Primary (US)
Users (EU) → Electric (EU) → Postgres Replica (EU)
↑ replication
Postgres Primary (US)
```skilldb get local-first-skills/electric-sqlFull skill: 433 linesElectricSQL
Sync Postgres data to local SQLite on every device. Full SQL locally, real-time sync when connected.
What ElectricSQL Is
ElectricSQL sits between your Postgres database and your client apps. It syncs subsets of your Postgres data ("shapes") to local SQLite databases on client devices. Reads and writes happen against local SQLite — fast, offline-capable, and reactive.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client A │ │ Electric │ │ Client B │
│ (SQLite) │◄───────►│ Service │◄───────►│ (SQLite) │
│ │ sync │ │ sync │ │
└─────────────┘ │ ┌────────┐ │ └─────────────┘
│ │Postgres│ │
│ └────────┘ │
└──────────────┘
Key characteristics:
- Postgres is the source of truth
- Clients get real SQLite databases (not a mock)
- Sync is shape-based (subscribe to subsets of data)
- Works offline; queues writes for later sync
- Conflict resolution via CRDTs built into the replication layer
Setup
Install Dependencies
# Core packages
npm install electric-sql wa-sqlite
# For React
npm install @electric-sql/react
# For React Native / Expo
npx expo install electric-sql expo-sqlite
Configure the Electric Service
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: myapp
POSTGRES_USER: electric
POSTGRES_PASSWORD: secret
ports:
- "5432:5432"
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://electric:secret@postgres:5432/myapp
ELECTRIC_WRITE_TO_PG_MODE: direct_writes
PG_PROXY_PORT: "65432"
AUTH_MODE: insecure # Use JWT in production
ports:
- "5133:5133" # Satellite (client sync) port
- "65432:65432" # PG proxy port
Database Migrations
Electrify your Postgres tables to enable sync.
-- Create a standard Postgres table
CREATE TABLE 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(),
user_id UUID NOT NULL
);
-- Enable Electric replication on the table
ALTER TABLE todos ENABLE ELECTRIC;
Shapes: Partial Sync
Shapes define what data syncs to each client. Clients subscribe to shapes and receive only matching rows.
import { ShapeStream, Shape } from 'electric-sql/client';
// Subscribe to a shape — only todos for this user
const stream = new ShapeStream({
url: 'http://localhost:5133/v1/shape',
table: 'todos',
where: `user_id = '${userId}'`,
});
const shape = new Shape(stream);
// Get current data
shape.subscribe((data) => {
const todos = [...data.values()];
console.log('Todos:', todos);
});
Shape Definitions
// Simple: all rows from a table
const allTodos = new ShapeStream({
url: ELECTRIC_URL,
table: 'todos',
});
// Filtered: rows matching a WHERE clause
const myTodos = new ShapeStream({
url: ELECTRIC_URL,
table: 'todos',
where: `user_id = '${userId}'`,
});
// Specific columns only
const todoTitles = new ShapeStream({
url: ELECTRIC_URL,
table: 'todos',
columns: ['id', 'title', 'completed'],
});
React Integration
useShape Hook
import { useShape } from '@electric-sql/react';
function TodoList({ userId }: { userId: string }) {
const { data: todos, isLoading, error } = useShape({
url: 'http://localhost:5133/v1/shape',
table: 'todos',
where: `user_id = '${userId}'`,
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{todo.title}
</li>
))}
</ul>
);
}
Writing Data
Writes go to the local SQLite database and sync to Postgres automatically.
import { useMutation } from '@electric-sql/react';
function AddTodo({ userId }: { userId: string }) {
const [title, setTitle] = useState('');
async function handleAdd() {
// Write directly to the API / local DB
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({
id: crypto.randomUUID(),
title,
completed: false,
user_id: userId,
}),
});
setTitle('');
}
return (
<form onSubmit={(e) => { e.preventDefault(); handleAdd(); }}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}
Live Queries
Electric provides reactive queries that automatically update when underlying data changes.
// The shape subscription is already reactive —
// every time the shape data changes, subscribers are notified.
const shape = new Shape(stream);
// This callback fires on every change
shape.subscribe((data) => {
// data is a Map<string, Row>
const todos = [...data.values()];
updateUI(todos);
});
// In React, useShape already handles reactivity
function Dashboard({ userId }: { userId: string }) {
const { data: todos } = useShape({
url: ELECTRIC_URL,
table: 'todos',
where: `user_id = '${userId}'`,
});
const completed = todos.filter((t) => t.completed).length;
const pending = todos.length - completed;
return (
<div>
<p>Completed: {completed}</p>
<p>Pending: {pending}</p>
</div>
);
}
Offline Support
Electric is offline-first by design. Writes are queued locally and synced when the connection is restored.
// Connectivity awareness
const stream = new ShapeStream({
url: ELECTRIC_URL,
table: 'todos',
});
// The stream handles reconnection automatically
// On reconnect, it catches up from where it left off
// For UI feedback on connectivity:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
function App() {
const isOnline = useOnlineStatus();
return (
<div>
<StatusBadge connected={isOnline} />
<TodoList />
{!isOnline && <p>Working offline. Changes will sync when reconnected.</p>}
</div>
);
}
Conflict Resolution
Electric handles conflicts at the replication layer. The default strategy is last-write-wins per column, but richer strategies are available.
-- Default: LWW per column (most recent write wins)
-- If Device A sets title='A' and Device B sets completed=true,
-- both changes are preserved (different columns).
-- If both set title, the latest timestamp wins.
-- For counters or other merge-friendly types,
-- use Postgres-level CRDTs (experimental):
-- Electric respects CRDT column annotations
Application-Level Conflict Handling
For complex cases, handle conflicts in your application logic:
// Server-side conflict resolution with Postgres triggers
// This trigger merges quantity changes additively instead of LWW
/*
CREATE OR REPLACE FUNCTION merge_inventory_quantity()
RETURNS TRIGGER AS $$
BEGIN
-- Instead of overwriting, add the delta
NEW.quantity := OLD.quantity + (NEW.quantity - OLD.quantity);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER inventory_merge
BEFORE UPDATE ON inventory
FOR EACH ROW
EXECUTE FUNCTION merge_inventory_quantity();
*/
Expo / React Native Setup
// app.tsx — Expo setup
import { electrify } from 'electric-sql/expo';
import { openDatabaseSync } from 'expo-sqlite';
import { schema } from './generated/client';
const conn = openDatabaseSync('myapp.db');
async function initElectric() {
const electric = await electrify(conn, schema, {
url: 'https://my-electric-service.com',
auth: {
token: await getAuthToken(),
},
});
return electric;
}
Deployment Patterns
Single Region
Users → CDN (static assets) → Electric Service → Postgres
Multi-Region with Read Replicas
Users (US) → Electric (US) → Postgres Primary (US)
Users (EU) → Electric (EU) → Postgres Replica (EU)
↑ replication
Postgres Primary (US)
Production Checklist
[ ] Enable JWT auth (AUTH_JWT_KEY environment variable)
[ ] Set up SSL/TLS for all connections
[ ] Configure Postgres connection pooling (PgBouncer)
[ ] Set appropriate shape filters (don't sync everything)
[ ] Monitor replication lag
[ ] Set up database backups (Postgres is the source of truth)
[ ] Load test with expected number of concurrent connections
[ ] Configure client retry/backoff for reconnection
Migration Strategies
-- Adding a new column (backwards compatible)
ALTER TABLE todos ADD COLUMN priority INTEGER DEFAULT 0;
-- Clients running old versions ignore the new column
-- New clients see the column and can use it
-- Renaming a column (breaking change)
-- 1. Add new column
ALTER TABLE todos ADD COLUMN task_title TEXT;
-- 2. Copy data
UPDATE todos SET task_title = title;
-- 3. Update clients to use new column
-- 4. After all clients updated, drop old column
ALTER TABLE todos DROP COLUMN title;
When to Choose ElectricSQL
Good fit:
- You already use Postgres and want to add local-first capabilities
- Your data model is relational and benefits from SQL
- You need server-side authority (Postgres is the source of truth)
- You want partial sync (shapes) rather than full replication
Less ideal:
- Pure peer-to-peer with no server
- Document-centric data (consider Yjs or Automerge)
- Non-Postgres backends
Install this skill directly: skilldb add local-first-skills
Related Skills
crdt-fundamentals
Teaches Conflict-free Replicated Data Types (CRDTs), the mathematical foundation for local-first sync. Covers how CRDTs guarantee eventual consistency without coordination, the difference between state-based and operation-based CRDTs, and practical implementations of G-Counter, PN-Counter, LWW-Register, OR-Set, G-Set, and RGA (Replicated Growable Array). Includes causal ordering, vector clocks, and guidance on choosing the right CRDT for your data model.
indexeddb-patterns
Teaches IndexedDB patterns for local-first web applications, using Dexie.js as the primary wrapper library. Covers schema design and versioning, creating indexes for efficient queries, transaction patterns, performance optimization (bulk operations, pagination, lazy loading), migration strategies for schema evolution, storage quota management, data export and import, and integration patterns with sync engines and reactive frameworks.
local-first-auth
Teaches authentication and authorization patterns for local-first applications that must work offline. Covers offline-capable auth with cached tokens, permission sync and local enforcement, encrypted local storage for sensitive data, key management with device-bound keys, device authorization and revocation, multi-device identity linking, end-to-end encryption for synced data, and secure patterns for handling auth in disconnected environments.
local-first-fundamentals
Teaches the local-first software paradigm where applications store data on the user's device, work fully offline, and sync to peers or servers when connectivity is available. Covers the spectrum from cloud-first to offline-first to local-first, core benefits (instant UX, offline capability, data ownership, privacy), key challenges (conflict resolution, sync complexity, storage limits), architectural patterns, and decision frameworks for when local-first is the right choice.
sync-engine-architecture
Teaches how to design and build a sync engine for local-first applications. Covers the operation log as the foundation, conflict resolution strategies (last-write-wins, operational transform, CRDTs), server reconciliation patterns, partial sync for large datasets, bandwidth optimization techniques, version vectors and causal consistency, clock synchronization, and practical implementation patterns with code examples.
yjs-sync
Teaches building local-first collaborative applications with Yjs, the most widely adopted CRDT library for JavaScript. Covers the Y.Doc document model, shared types (Y.Map, Y.Array, Y.Text, Y.XmlFragment), the awareness protocol for presence and cursors, persistence and sync providers (WebSocket, WebRTC, IndexedDB), integrating with editors like ProseMirror/TipTap/CodeMirror/Monaco, undo/redo management, and performance optimization patterns.