Skip to main content
Technology & EngineeringLocal First433 lines

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.

Quick Summary31 lines
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 lines
Paste into your CLAUDE.md or agent config

ElectricSQL

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

Get CLI access →

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.

Local First454L

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 First556L

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 First606L

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.

Local First285L

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.

Local First572L

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.

Local First471L