Skip to content

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

FixDevs ·

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

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.

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.

For related real-time and database issues, see Fix: Supabase Not Working and Fix: Neon Database Not Working.

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