Skip to content

Fix: ElectricSQL Not Working — Sync Not Starting, Shapes Empty, or Postgres Connection Failing

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix ElectricSQL issues — Postgres setup with logical replication, shape definitions, real-time sync to the client, React hooks, write-path through the server, and deployment configuration.

The Problem

The Electric sync service doesn’t start:

Error: could not connect to Postgres: connection refused

Or shapes return empty data even though Postgres has rows:

const { data } = useShape({ url: `${ELECTRIC_URL}/v1/shape`, table: 'todos' });
console.log(data);  // [] — empty

Or real-time updates don’t arrive after inserting data:

Insert into Postgres succeeds but the client shape doesn't update

Production Incident: Stale Data After an Offline Session

Sync engines fail in the most user-visible way possible: people see old data and act on it. When ElectricSQL stops propagating updates, the symptoms are intermittent and confusing. A user marks a task complete on their laptop, opens the same dashboard on their phone, and the task still appears open. They mark it done again. A few minutes later both versions sync, and now there are duplicates, mismatched timestamps, or — worse — a stale “completed” flag overwriting a fresh edit.

The classic incident pattern is the WAL replication slot quietly filling Postgres disk. ElectricSQL holds an open replication slot to stream changes. If the Electric service is down for hours but its slot remains, Postgres preserves WAL segments until Electric reconnects. Disk usage climbs, sometimes to the point of triggering a PgBouncer or RDS outage. Always monitor pg_replication_slots.active = false for ElectricSQL’s slot and pg_wal_lsn_diff — page on-call if the slot has been inactive for more than 5 minutes.

A second incident class is shape drift after a deploy. A shape is defined by table + columns + where clause. If you rename a column in a Postgres migration but don’t update the shape definition shipped in the new client bundle, every connected client suddenly sees an empty shape. Users on the old bundle keep working; users on the new bundle see a broken UI. Treat shape definitions as a contract between client and server — bump shape versions explicitly and roll out client and database changes together.

A third pattern is the offline-edit conflict. ElectricSQL syncs Postgres → client; writes still go through your API. If two users edit the same row while one is offline, their edits land in Postgres in the order they reconnect. Last-write-wins is the default. For collaborative paths (shared docs, joint workspaces), build conflict UI: show users when their stored value differs from what they last saw, and let them resolve before the merge completes.

Why This Happens

ElectricSQL syncs data from Postgres to the client in real-time using logical replication. The architecture has several moving parts:

  • Postgres must have logical replication enabled — ElectricSQL reads the Postgres write-ahead log (WAL) to detect changes. Without wal_level = logical in postgresql.conf, the sync service can’t start. Most managed Postgres providers (Neon, Supabase, RDS) support this but may need explicit configuration.
  • Shapes define what data syncs — a “shape” is a subset of a table (like a query). The client subscribes to shapes, and Electric streams matching rows. If the shape definition doesn’t match the table name or has an incorrect where clause, zero rows sync.
  • Electric is read-only on the client — ElectricSQL syncs data from Postgres to the client. Writes go through your existing API/server to Postgres. Electric detects the change via WAL and syncs it to all connected clients. Direct client writes aren’t supported in the current architecture.
  • The Electric sync service is a separate process — it runs alongside your app and connects to Postgres. The client connects to the sync service (not directly to Postgres) over HTTP.

A subtler reason shapes return empty data is replica identity. Postgres’s logical replication needs to know which columns identify a row for UPDATE and DELETE events. The default REPLICA IDENTITY DEFAULT only logs the primary key. If your shape filters on a column that isn’t the primary key, updates to that column may not propagate (the WAL record doesn’t contain enough info). For tables with non-PK filter columns, set ALTER TABLE table REPLICA IDENTITY FULL so all columns are logged. This trades some WAL volume for correctness.

A second reason is the auth boundary. Electric serves shape data over HTTP without per-client auth by default. If you put it behind a public URL, anyone can subscribe to any shape they can guess. Production deployments must proxy /v1/shape requests through your own backend, validate the user’s session, and only allow shapes the user is authorized to see. Without this, a user can sniff the network tab, swap where: "user_id = 'me'" for someone else’s ID, and read their data.

A third reason for failed real-time updates is connection drop handling. Electric clients use long-polling or streaming HTTP. On flaky mobile networks, the connection drops constantly. The client must reconnect with the last known offset so it picks up exactly where it left off — duplicate or missing rows otherwise. Verify with a chaos test: kill the network for 30 seconds, then restore it, and confirm the shape ends in the same state as a freshly loaded client.

Fix 1: Set Up Postgres and Electric

# Docker Compose — Electric + Postgres
# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    command:
      - -c
      - wal_level=logical         # Required for Electric
      - -c
      - max_replication_slots=10
      - -c
      - max_wal_senders=10
    ports:
      - '5432:5432'
    volumes:
      - pgdata:/var/lib/postgresql/data

  electric:
    image: electricsql/electric
    environment:
      DATABASE_URL: postgresql://postgres:postgres@postgres:5432/myapp
    ports:
      - '3000:3000'
    depends_on:
      - postgres

volumes:
  pgdata:
docker compose up -d

# Verify Electric is running
curl http://localhost:3000/v1/health
# {"status":"active"}
-- Create your tables
CREATE TABLE todos (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  completed BOOLEAN DEFAULT false,
  user_id TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Enable the table for Electric sync
-- (Electric auto-detects tables, but you can configure which ones to expose)

Fix 2: Client-Side Shape Subscription

npm install @electric-sql/client
# For React:
npm install @electric-sql/react
// lib/electric.ts
const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || 'http://localhost:3000';

export { ELECTRIC_URL };
// Without React — plain Shape subscription
import { ShapeStream, Shape } from '@electric-sql/client';

const stream = new ShapeStream({
  url: `${ELECTRIC_URL}/v1/shape`,
  table: 'todos',
  where: `user_id = 'user-123'`,
});

const shape = new Shape(stream);

// Get current data
const data = shape.currentValue;
console.log([...data.values()]);

// Subscribe to changes
shape.subscribe((updatedData) => {
  console.log('Updated:', [...updatedData.values()]);
});
// With React — useShape hook
'use client';

import { useShape } from '@electric-sql/react';
import { ELECTRIC_URL } from '@/lib/electric';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
  user_id: string;
  created_at: string;
}

function TodoList({ userId }: { userId: string }) {
  const { data: todos, isLoading, error } = useShape<Todo>({
    url: `${ELECTRIC_URL}/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>
  );
}

// Write through your API — Electric syncs changes back automatically
async function toggleTodo(todoId: string) {
  await fetch(`/api/todos/${todoId}/toggle`, { method: 'PATCH' });
  // No need to update local state — Electric syncs the change in real-time
}

async function addTodo(userId: string, title: string) {
  await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId, title }),
  });
  // Electric syncs the new row automatically
}

Fix 3: Server-Side Write Path

// app/api/todos/route.ts — writes go to Postgres, Electric syncs to clients
import { db } from '@/lib/db';
import { todos } from '@/lib/schema';

export async function POST(req: Request) {
  const { userId, title } = await req.json();

  const result = await db.insert(todos).values({
    id: crypto.randomUUID(),
    title,
    user_id: userId,
    completed: false,
  }).returning();

  // Return immediately — Electric syncs to all connected clients
  return Response.json(result[0], { status: 201 });
}

// app/api/todos/[id]/toggle/route.ts
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
  const { id } = await params;

  await db.execute(sql`
    UPDATE todos SET completed = NOT completed WHERE id = ${id}
  `);

  return new Response(null, { status: 204 });
}

// app/api/todos/[id]/route.ts
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
  const { id } = await params;
  await db.delete(todos).where(eq(todos.id, id));
  return new Response(null, { status: 204 });
}

Fix 4: Shape Configuration Options

// Basic shape — all rows from a table
useShape({ url: `${ELECTRIC_URL}/v1/shape`, table: 'todos' });

// Filtered shape — only matching rows sync
useShape({
  url: `${ELECTRIC_URL}/v1/shape`,
  table: 'todos',
  where: `user_id = 'user-123' AND completed = false`,
});

// Select specific columns (reduces bandwidth)
useShape({
  url: `${ELECTRIC_URL}/v1/shape`,
  table: 'todos',
  columns: ['id', 'title', 'completed'],
});

// Multiple shapes — subscribe to different tables
function Dashboard({ userId }: { userId: string }) {
  const { data: todos } = useShape<Todo>({
    url: `${ELECTRIC_URL}/v1/shape`,
    table: 'todos',
    where: `user_id = '${userId}'`,
  });

  const { data: projects } = useShape<Project>({
    url: `${ELECTRIC_URL}/v1/shape`,
    table: 'projects',
    where: `owner_id = '${userId}'`,
  });

  return (
    <div>
      <h2>Todos ({todos.length})</h2>
      <h2>Projects ({projects.length})</h2>
    </div>
  );
}

Fix 5: Enable Logical Replication on Managed Postgres

-- Check current WAL level
SHOW wal_level;
-- Must be 'logical', not 'replica' or 'minimal'

-- Neon: Enable in project settings → Logical Replication → Enable
-- Supabase: Already enabled by default
-- AWS RDS: Set rds.logical_replication = 1 in parameter group, reboot
-- Google Cloud SQL: Set cloudsql.logical_decoding = on
-- Azure: Set azure.replication = logical
# Verify Electric can connect
curl "http://localhost:3000/v1/shape?table=todos&offset=-1"
# Should return shape data or empty array

# Check Electric logs for errors
docker compose logs electric

Fix 6: Optimistic UI Updates

'use client';

import { useShape } from '@electric-sql/react';
import { useState } from 'react';

function TodoApp({ userId }: { userId: string }) {
  const { data: todos } = useShape<Todo>({
    url: `${ELECTRIC_URL}/v1/shape`,
    table: 'todos',
    where: `user_id = '${userId}'`,
  });

  // Optimistic state for pending operations
  const [optimistic, setOptimistic] = useState<Map<string, Partial<Todo>>>(new Map());

  async function toggleTodo(todo: Todo) {
    const optimisticId = todo.id;

    // Optimistic update
    setOptimistic(prev => new Map(prev).set(optimisticId, {
      completed: !todo.completed,
    }));

    try {
      await fetch(`/api/todos/${todo.id}/toggle`, { method: 'PATCH' });
    } catch {
      // Revert optimistic update on error
      setOptimistic(prev => {
        const next = new Map(prev);
        next.delete(optimisticId);
        return next;
      });
    }

    // Clear optimistic state after Electric syncs the real update
    setTimeout(() => {
      setOptimistic(prev => {
        const next = new Map(prev);
        next.delete(optimisticId);
        return next;
      });
    }, 2000);
  }

  // Merge Electric data with optimistic updates
  const mergedTodos = todos.map(todo => ({
    ...todo,
    ...(optimistic.get(todo.id) || {}),
  }));

  return (
    <ul>
      {mergedTodos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo)}
          />
          {todo.title}
        </li>
      ))}
    </ul>
  );
}

Still Not Working?

Electric can’t connect to Postgres — verify the DATABASE_URL includes the correct host, port, database name, and credentials. If using Docker Compose, the host is the service name (postgres), not localhost. Also check that wal_level = logical is set — Electric requires logical replication.

Shape returns empty data — the table name must match exactly (case-sensitive). Also check the where clause syntax — it uses Postgres SQL syntax, not JavaScript. Single quotes for strings: where: "user_id = 'abc'". If the table exists but has no rows, an empty result is expected.

Changes in Postgres don’t sync to clients — Electric reads the WAL (write-ahead log). If changes bypass the WAL (e.g., COPY without WAL, or changes made before Electric connected), they won’t sync. Changes made through normal INSERT/UPDATE/DELETE after Electric is connected will sync.

Shape subscription causes high bandwidth — shapes sync the full matching dataset on first load. For large tables, use where clauses to limit the data and columns to select only needed fields. Each connected client maintains its own shape subscription.

Replication slot fills disk after Electric is offline — Postgres preserves WAL segments while a replication slot exists, even if the consumer is gone. Monitor pg_replication_slots and drop stale slots manually when Electric is decommissioned. For production, alert on pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) > 1GB for any slot.

Updates to non-PK columns don’t propagate — Postgres’s default replica identity only logs the primary key on UPDATE. If a shape’s where clause references a non-PK column and that column changes, Electric can’t tell. Run ALTER TABLE todos REPLICA IDENTITY FULL for affected tables so every column is logged. Watch WAL volume after this change.

Anyone can subscribe to any shape over public URL — Electric serves shapes over HTTP with no auth by default. In production, never expose /v1/shape publicly. Put it behind a reverse proxy that validates the user’s session cookie and rewrites the where clause to scope rows to the current user. Client-supplied filters must never be trusted.

For related database and real-time issues, see Fix: Supabase Realtime Not Working, Fix: Neon Database Not Working, Fix: Postgres Connection Refused, and Fix: Prisma Connection Pool Exhausted.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles