Mikan Labs Logo
MikanLabs
Back to blog

Electric SQL in Production: Lessons Learned Building Local-First Apps

What we learned using Electric SQL to build real-time, local-first applications. SSE in hosted environments, managing complexity, and avoiding memory leaks.

electric-sql local-first

We’ve been building production applications with Electric SQL, a sync engine that streams data from Postgres into local apps in real-time. Some things worked well. Others were harder than expected. Here’s what we learned.

Why Electric SQL

We already use Postgres. That’s the main reason. Electric sits on top of your existing database—you add a sync layer without migrating to a new system.

It’s also open source, self-hostable, and modular. If you’re starting fresh without existing Postgres infrastructure, Convex offers a more integrated experience and is open source and self-hostable too. But if Postgres is your foundation, Electric is the natural choice.

How It Works

Electric’s core primitive is the Shape—a subset of a single Postgres table, optionally filtered by a WHERE clause. Clients subscribe to shapes and receive updates via Server-Sent Events (SSE).

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

Each shape opens a persistent SSE connection. Five shapes means five connections streaming updates. This matters for what comes next.

SSE in Hosted Environments

This caused us the most trouble. SSE works perfectly in local development, then breaks in production.

Connection Timeouts

In hosted environments, idle HTTP connections often get terminated after ~60 seconds. Your SSE streams die silently, returning 502 errors and MissingHeadersError.

The fix: Enable liveSse: true to send keep-alive heartbeats every 21 seconds:

const shapeOptions = {
  liveSse: true,
};

Proxy Buffering

Reverse proxies buffer responses by default. SSE needs immediate delivery. Without proper configuration, events batch up and connections appear to hang.

The fix: Add anti-buffering headers to your proxy responses:

headers.set('x-accel-buffering', 'no');  // nginx
headers.set('cache-control', 'no-cache, no-store, no-transform');
headers.set('connection', 'keep-alive');

The lesson: Test on production-like infrastructure early. SSE behavior on localhost tells you nothing about behavior on the server.

Complexity Scaling

Electric works beautifully when your data access is straightforward:

  • User-scoped data (todos filtered by user_id)
  • Simple authorization (users own their records)
  • A handful of concurrent shapes

Subscribe to a few shapes, data flows, the local-first experience feels great.

Where It Gets Harder

Shapes are single-table only. No JOINs, no nested includes. Data from three related tables means three shape subscriptions.

Shape definitions are immutable. Can’t modify a WHERE clause mid-subscription. Different filters means a new subscription and cleaning up the old one.

Multi-tenancy multiplies everything. Users in multiple organizations means scoping every shape by org. Switching orgs means cleaning up all shapes and resubscribing with new filters.

We’ve built apps with 7 concurrent shapes (manageable) and apps approaching 30 (painful). At higher counts, you’re managing shape lifecycles, building cleanup strategies, maintaining caches for scoped collections. The sync layer becomes infrastructure you maintain.

For complex multi-tenant apps with sophisticated authorization, an integrated backend may save you time. For simpler apps where you want control over your Postgres database, Electric’s modular approach is a strength.

Memory Leaks and Connection Accumulation

Each shape subscription opens an SSE connection that lives until you explicitly close it. This is easy to miss.

User logs in. Subscribes to shapes. Navigates around. Logs out. Logs in as a different user. Those original connections? Still open. Now you have twice as many. The app slows down. Users refresh to fix it temporarily.

The Fix

Clean up on logout, user switch, org switch—any context change:

const handleLogout = async () => {
  cleanupAllShapes();      // Close SSE connections
  clearRetryState();       // Cancel pending retry timeouts
  queryClient.clear();     // Clear React Query cache
  await signOut();
};

For multi-org apps, you’ll want a caching strategy for organization-scoped collections so old ones get cleaned up when users switch contexts.

Verifying It Works

Open DevTools, Network tab, filter by “EventStream.” You’ll see your shape connections. After logout, they should all close. If they don’t, you have a leak.

Heap snapshots help too. Baseline after login, another after navigation, another after logout. Memory should return close to baseline. If it grows continuously, connections are accumulating.

What We’d Do Differently

  1. Test on production infrastructure early. Don’t discover SSE problems at launch.

  2. Build cleanup into the architecture from the start. Retrofitting it after users report slowdowns is painful.

  3. Consolidate shapes. Each one is operational overhead. Fewer is better.

  4. Be honest about complexity. Electric excels at straightforward data access. If your app has complex multi-tenant authorization across many related tables, evaluate whether the modular approach is actually saving you time.

Bottom Line

Electric SQL is a solid choice for adding real-time sync to Postgres-backed applications with straightforward data access patterns. The main challenges are SSE configuration in hosted environments and managing shape complexity as your app grows.

Go in knowing you’ll handle connection lifecycle yourself, configure proxies for SSE, and think carefully about how many shapes you need. With those expectations set, it works well.


Resources