If you've looked at local-first sync at all, you've probably hit the same wall I did: the good
tooling (Zero, Replicache, Electric's own cloud) either wants you to move your data into their
managed service, or wants a chunk of monthly spend before you've even shipped a prototype. I
wanted to try Electric's actual open-source sync engine against a Postgres I already run, on
infra I already pay for.
Turns out that's the easy part — Electric's OSS core is just a Docker image. Here's what it
actually does and how to stand it up.
What Electric actually is
Electric is a read-path sync engine for Postgres. It doesn't replace your database or your
writes — you keep writing to Postgres however you already do (your API, an ORM, whatever). What
Electric does is watch Postgres's logical replication stream and re-serve subsets of your data
to clients in real time, over plain HTTP.
The abstraction is a Shape: you ask for a subset of a table (GET /v1/shape?table=todos),
Electric syncs you the current rows plus a live stream of every insert/update/delete that matches,
keyed by a resumable offset. Clients don't poll — they hold a long-lived connection and get
pushed diffs. On the frontend, TanStack DB's useShape/useLiveQuery hooks turn that stream into
a normal reactive local collection, so your components just read local state and it happens to
stay in sync with Postgres. No loading spinners, no manual refetch-on-mutation code.
The catch, and it's a real one: Postgres needs wal_level = logical. On Railway's managed
Postgres that's one ALTER SYSTEM SET + a restart. If you're on a Postgres you don't control
(some managed providers lock this down), you can't use Electric against it at all — check that
before you invest time.
Running it
The whole self-hosted footprint is one container:
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
-e ELECTRIC_SECRET="something-long-and-random" \
-e ELECTRIC_STORAGE_DIR="/var/lib/electric/persistent" \
-v electric_data:/var/lib/electric/persistent \
electricsql/electric:latest
That's it — no separate control plane, no extra database. ELECTRIC_STORAGE_DIR needs a real
volume behind it (Electric caches shape logs there), so don't skip the mount or you'll be
re-syncing everything from scratch on every restart.
I maintain a one-click Railway template that wraps this (disclosure: I get a kickback if you
deploy through it): https://railway.com/deploy/electricsql-1?referralCode=Z1xivh&utm_medium=integration&utm_source=template&utm_campaign=devto
— point DATABASE_URL at your existing Railway Postgres (or any Postgres with logical
replication on), set a secret, done. If you'd rather not go through a referral link, the raw
image above runs identically anywhere Docker does — Railway's just where I happen to already run
my Postgres, so it was the path of least resistance for me.
Wiring up a client
On the frontend, TanStack DB is the least-friction path in:
import { useLiveQuery } from '@tanstack/react-db'
import { electricCollectionOptions } from '@tanstack/electric-db-collection'
const todos = electricCollectionOptions({
shapeOptions: { url: 'https://your-electric-host/v1/shape', params: { table: 'todos' } },
})
function TodoList() {
const { data } = useLiveQuery((q) => q.from({ todos }))
return data.map((t) => <div key={t.id}>{t.text}</div>)
}
Writes still go wherever they already go (your API, direct Postgres, whatever) — Electric only
owns the read path back down to the client.
What it's actually good for, and what it isn't
This is the honest part: Electric is not a general real-time-everything button. It shines when
you have a genuinely large or complex dataset and you want a subset of it live on the client
without hand-rolling websocket fan-out and cache invalidation — dashboards, collaborative tools,
anything with "this view should just always be current." If you only need to push a handful of
events to a handful of connected clients, plain websockets or Postgres LISTEN/NOTIFY is less
infrastructure for the same result.
Resource-wise it's light — the container idles well under 256MB for a small Shape set — but shape
storage grows with how much history you're retaining, so watch ELECTRIC_STORAGE_DIR if you're
syncing high-churn tables.
Top comments (1)
The “read path only” distinction is especially helpful. The production checklist I’d add is mostly about logical-replication debt: alert on slot-retained WAL bytes and disk headroom, not just time lag; verify replica identity for updates and deletes; test schema changes against active Shapes; and rehearse restart, slot loss, and full re-snapshot behavior. A cheap container can become an expensive outage if a stalled slot quietly retains WAL until Postgres fills its disk.
I’d also keep Shape authorization separate from
ELECTRIC_SECRET. Don’t let untrusted clients choose arbitrary tables or predicates. Issue server-generated, tenant-bound Shape URLs or tokens, authorize every subscription, and include the tenant plus policy version in cache keys. Logical replication is not a substitute for row-level access control at the Shape endpoint.For user-visible correctness, expose both the resumable offset and a source freshness marker, then test high-churn backpressure and reconnect storms. “Reactive” is only useful if the UI can tell current, catching up, and resyncing apart.