Real-time PostgreSQL Sync to the Browser with ElectricSQL Shapes API — A sync-engine Pattern for Connecting Offline Writes and Server DB in Local-First Web Apps
When implementing real-time features directly with WebSocket, you inevitably end up staring at a file where reconnection logic has grown to twice the size of your business logic. Connection state tracking, offline queuing, state reconciliation on reconnect — this infrastructure code devours your service's core functionality.
ElectricSQL is designed to "standardize read-path synchronization with PostgreSQL logical replication + HTTP Long-Polling, so developers can focus purely on business logic." This article covers how the Shapes API works, read/write path separation with authorization handling, and four patterns for connecting offline writes to a server DB.
One thing worth clarifying upfront: the phrase "automatic merging" might evoke CRDTs or conflict-resolution algorithms, but what Electric actually provides is different. Electric automates the read path — the part that streams Postgres changes to clients. The write path is something you design yourself using a pattern of your choice, and once those writes land in Postgres, Electric propagates them to clients. The combination of these two paths is what "connecting offline writes to a server DB" means. Conflict resolution remains the responsibility of your server API.
Core Concepts
ElectricSQL and Logical Replication
ElectricSQL (Electric for short) is an open-source sync engine that sits between PostgreSQL and browsers/clients. It subscribes to the WAL (Write-Ahead Log) via PostgreSQL's logical replication feature and streams DB changes to clients as they happen. It is implemented in Elixir, licensed under Apache 2.0, and can be self-hosted.
Reads go through the Electric path (via an auth proxy — covered in detail below), while writes are handled by the existing API. This path separation means you can add a sync layer without touching your existing REST API or GraphQL.
Shapes: The Core Unit of Synchronization
A Shape is the fundamental unit of synchronization in Electric. It is a subscription concept that declaratively defines a subset of rows in a specific table, with the scope specified via a SQL WHERE clause.
A request to subscribe to "the logged-in user's todos" looks like this:
GET /v1/shape?table=todos&where=user_id%3D%27abc-123%27&offset=-1offset=-1 means "give me the entire shape log from the beginning." The server responds in the form of a shape log, and if the log is large, it returns the next cursor position via the electric-offset header. Subsequent requests pick up from that offset, enabling incremental synchronization.
If you define a Shape's scope too broadly, the initial load cost grows significantly. Subscribing only to the data you need via a
WHEREclause is the key to good design. However, note thatWHEREclause scoping is a performance optimization, not a security authorization boundary. Authorization must always be enforced server-side, which is covered in the next section.
How the HTTP Shapes API Works
Electric's real-time streaming operates via HTTP Long-Polling, not WebSocket. With the ?live=true parameter, the server holds the connection open and waits until new data arrives.
Upon receiving a response, the client immediately sends the next long-poll request. This loop repeats without interruption, maintaining the real-time stream. This architecture allows you to implement a real-time stream using only standard HTTP — no separate WebSocket infrastructure required — with CDN caching support and compatibility with existing monitoring tools.
On the React client side, the useShape hook from the @electric-sql/react package abstracts all of this logic. The moment a component mounts, the shape subscription begins, and data updates automatically whenever a change occurs in Postgres. Reconnection logic, offset management, and state updates are all handled inside the hook.
Read/Write Path Separation and Authorization
Electric only handles the read path (PostgreSQL → client). Electric does not handle writes. This means you can keep your existing REST API, GraphQL, or server actions as-is and simply add a sync layer on top — which is one of Electric's core design strengths.
On the read path, authorization is something you must address explicitly.
Electric has no built-in authorization. The structure allows a client to manipulate the where parameter arbitrarily and subscribe to another user's data. The solution is to place an auth proxy in front of Electric. The client calls your own endpoint, the server validates the token, and then sets the where clause itself before forwarding the request to Electric.
// Server-side (e.g., Next.js Route Handler) — /api/todos-shape
export async function GET(req: Request) {
const session = await getServerSession(req)
if (!session) {
return new Response('Unauthorized', { status: 401 })
}
const electricUrl = new URL(`${process.env.ELECTRIC_URL}/v1/shape`)
electricUrl.searchParams.set('table', 'todos')
// Set server-side from session — no client input used
electricUrl.searchParams.set('where', `user_id = '${session.userId}'`)
// Pass Electric parameters (offset, live, etc.) through from the client
const incoming = new URL(req.url)
for (const [key, value] of incoming.searchParams) {
if (['offset', 'live', 'cursor'].includes(key)) {
electricUrl.searchParams.set(key, value)
}
}
return fetch(electricUrl.toString())
}// Client-side — does not access Electric directly
function TodoList() {
const { data: todos, isLoading } = useShape({
url: '/api/todos-shape',
})
if (isLoading) return <div>Syncing...</div>
return (
<ul>
{todos.map(todo => (
<li
key={todo.id}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.title}
</li>
))}
</ul>
)
}In this pattern, write-path authorization is handled by the existing API as before. Because the read and write paths are independent, their respective authorization logic separates naturally.
Practical Application
Choosing Among 4 Write Patterns
The write path is something you must choose yourself. The official Electric documentation presents four patterns, and which one to choose depends on your offline requirements and your tolerance for complexity.
Pattern 1: Online Writes — The simplest option. Use your existing API as-is; Electric only handles read synchronization. Writes fail when offline, but for most B2B SaaS applications, this is sufficient.
Pattern 2: Optimistic State — Reflects local state immediately in slow network conditions, with Electric syncing server changes after the API responds. This is the most commonly used pattern.
const [localOverrides, setLocalOverrides] = useState<Record<string, Partial<Todo>>>({})
async function toggleTodo(id: string, currentCompleted: boolean) {
const newCompleted = !currentCompleted
// Reflect immediately in the UI
setLocalOverrides(prev => ({ ...prev, [id]: { completed: newCompleted } }))
try {
await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: newCompleted }),
})
// Clear the optimistic state right after API success.
// This runs before Electric sync completes, so the pre-server state
// may be briefly visible (flicker). To prevent flicker,
// perform this clear in useShape's change callback.
setLocalOverrides(prev => {
const next = { ...prev }
delete next[id]
return next
})
} catch {
setLocalOverrides(prev => {
const next = { ...prev }
delete next[id]
return next
})
}
}
// At render time: local overrides take precedence over server data
const mergedTodos = todos.map(todo => ({ ...todo, ...localOverrides[todo.id] }))Pattern 3: Shared Persistent Optimistic State — Stores optimistic state in persistent storage like IndexedDB to improve consistency across components. Work done offline is preserved even after a page refresh.
Pattern 4: Through-the-Database Sync — The most powerful, but also the most complex. Combined with PGlite (an embedded Postgres in the browser), application code reads and writes only to a local DB.
A Look at the Through-the-Database Sync Implementation
This pattern maintains two tables locally.
-- Synced table that Electric populates with server data
CREATE TABLE todos_synced (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
user_id TEXT NOT NULL
);
-- Local changes not yet sent to the server
CREATE TABLE todos_local (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
user_id TEXT NOT NULL,
is_pending BOOLEAN DEFAULT TRUE
);
-- The view the application reads: local changes override server data
CREATE VIEW todos AS
SELECT * FROM todos_local WHERE is_pending = TRUE
UNION ALL
SELECT s.* FROM todos_synced s
WHERE s.id NOT IN (
SELECT id FROM todos_local WHERE is_pending = TRUE
);Application code writes to todos_local and reads from the todos view.
One point that needs explicit explanation: todos_local rows that have been successfully sent to the server (is_pending = FALSE) do not appear in this view. A background worker handles these rows. The worker sends rows where is_pending = TRUE to the server API, and on success, updates is_pending to FALSE. Once Electric sync reflects the server response in todos_synced, the worker deletes the corresponding todos_local row. From that point on, the view exposes the latest data from todos_synced.
This pattern carries significant implementation complexity. There are also context-loss issues during rollback handling, and local schema management is non-trivial. For a simple CRUD app, it is more pragmatic to start with Pattern 2 or 3.
A Real Production Case: Trigger.dev
Trigger.dev (an open-source background job platform) uses Electric to sync real-time job execution status to its dashboard. This use case — handling tens of thousands of changes per second — directly triggered the Electric v1.1 storage engine redesign.
v1.1 was released in August 2025 and achieved more than 100x improvement in write performance and more than 70x improvement in read performance over the previous version (figures from the Electric v1.1 release blog). The storage engine was rebuilt from scratch to address high CPU usage and read-blocking issues during large transactions.
Pros and Cons
Summary of Advantages
| Advantage | Description |
|---|---|
| Reuse existing infrastructure | Add as a layer with no Postgres schema changes. Existing APIs are preserved |
| Declarative API | Define one Shape and get a real-time stream. No WebSocket server needed |
| Standard HTTP-based | CDN caching supported, fewer firewall issues, reuse existing monitoring tools |
| Open source + self-hosted | Apache 2.0 license. No vendor lock-in |
| PGlite integration | Combined with in-browser Postgres, delivers a fully local-first experience with no network latency |
Drawbacks and Considerations
| Drawback | Description |
|---|---|
| No write path support | Client writes must be implemented by the developer |
| No built-in authorization | Shape authorization requires a separate server-side proxy implementation |
| Through-the-DB complexity | Full local-first requires complex local schemas and worker logic |
| No conflict resolution | Complex business logic conflict resolution must be implemented separately in the server API |
| Large shape cost | Without scoping via a WHERE clause, initial load cost surges |
| PostgreSQL only | Non-Postgres databases such as MySQL and MongoDB are not supported |
| PGlite write sync is experimental | Write sync from PGlite to the server is still experimental. Check the latest release notes before production use |
Comparison with Competing Tools
| Tool | Strengths | When to Choose |
|---|---|---|
| ElectricSQL | Reuse existing Postgres, web DX | Postgres-based teams that want to keep their existing API |
| PowerSync | Mature mobile production track record | Mobile apps, SQLite-based environments |
| Zero (Rocicorp) | Fine-grained reactive queries, web DX | Postgres-based but a different architecture requiring its own zero-cache server |
| TanStack DB | Fine-grained reactive UI | Independent client DB that supports Electric as one of its adapters |
Common Mistakes in Practice
1. Treating the WHERE clause as a security boundary
The where parameter can be arbitrarily tampered with by the client. If the client accesses Electric directly without a server-side auth proxy, someone can open browser developer tools, modify the where value, and subscribe to another user's data.
// Dangerous: client can arbitrarily tamper with the where parameter
useShape({
url: `${ELECTRIC_URL}/v1/shape`,
params: { table: 'todos', where: `user_id = '${userId}'` },
})
// Correct approach: set the where clause server-side via an auth proxy
useShape({ url: '/api/todos-shape' })2. Defining the Shape scope too broadly
Filtering user data with a where clause in the server-side auth proxy is also essential from a performance standpoint. Subscribing to an entire table causes the initial load cost to spike.
// Scope must always be restricted in the auth proxy
electricUrl.searchParams.set('where', `user_id = '${session.userId}'`)3. Assuming Electric handles writes too
To modify data received via useShape, a separate API call is always required. Electric only handles sync after those changes have landed in Postgres.
4. Delivering server round-trip UX without optimistic updates
Even in normal conditions, server round-trip latency is perceptible. Applying optimistic updates even to a simple toggle makes a dramatic difference in user experience. Starting with Pattern 2 is sufficient.
Closing Thoughts
The core of ElectricSQL is standardizing "read-path synchronization" with Postgres logical replication + HTTP Long-Polling. You can implement a real-time stream without WebSocket infrastructure and add a sync layer without touching your existing API. Conflict resolution and write authorization remain areas you design yourself.
Adopting write patterns incrementally based on requirements is the pragmatic approach. Most apps are well served by Pattern 1 (Online Writes) or Pattern 2 (Optimistic State), and Pattern 4 (Through-the-DB) should only be considered when a fully local-first experience is truly required. With production-scale cases like Trigger.dev handling real traffic volumes, local-first architecture is moving from "experimental" to "production-viable."
To get started, three steps are recommended:
-
Run Electric locally with Docker and subscribe to an existing Postgres table using the
useShapehook. This requires Docker, Postgres logical replication configuration, and running the Electric server — but once complete, you can see Postgres changes reflected in the browser in real time. -
Replace one real-time feature in an existing app that currently uses polling or WebSocket with a Shape. This lets you gauge migration cost and impact at a small scale.
-
Identify your offline scenarios, then choose the appropriate Write pattern. Use the decision tree above as a guide and try applying Optimistic State incrementally from there.
References
electric-sql.comandelectric.axare both official ElectricSQL domains; visiting one may redirect to the other.
- Electric Official Docs - Shapes Guide
- Electric HTTP API Reference
- Electric Official Docs - Writes Guide (4 Patterns)
- Electric TypeScript Client Docs
- GitHub: electric-sql/electric
- GitHub: electric-sql/pglite
- PGlite Official Docs - Electric sync
- Electric v1.1 Release Blog - 100x faster writes
- Electric BETA Release Blog (2024.12)
- Local-first with your existing API - ElectricSQL Blog
- TanStack DB + ElectricSQL Integration - Neon Blog
- ElectricSQL vs PowerSync vs Zero Comparison (2026)
- Comparing local-first frameworks - Neon
- LogRocket: Building a local-first app with ElectricSQL
- Building an offline first E2E encrypted web app with PGlite and Electric
- GitHub: write-patterns example code
- GitHub: LinearLite clone example
- DeepWiki: ElectricSQL System Architecture
- Local-First Architecture: CRDTs & Sync Engines 2026