Mirroring Server Postgres in the Browser in Real Time — Building Local-First Apps with ElectricSQL Shapes and PGlite
An app where the UI must respond instantly even on unstable networks, work offline, and keep data synchronized across multiple users in real time. Trying to implement all three simultaneously — stacking a WebSocket server, Redux, a cache layer, and an offline queue one after another — makes the codebase unmanageably complex.
The paradigm that addresses all three requirements at once is Local-First. The client keeps a local copy of the relevant portion of the server DB, and the UI always reads only from local storage. The UI responds instantly regardless of network state, while synchronization with the server happens in the background.
The cleanest combination for implementing this pattern on the web today is ElectricSQL Shapes + PGlite. Server PostgreSQL changes are received as an ElectricSQL Shape stream, applied in real time to PGlite (a browser-embedded PostgreSQL), and offline writes are immediately reflected in the UI via optimistic updates. This article covers how it works, real code, and the pitfalls commonly encountered in production — in that order.
Core Concepts
Local-First Architecture
In the traditional client-server model, the UI fetches data directly from the server DB. A slow network means a slow UI; going offline means the app stops working. Local-First reverses this flow. The client keeps a local copy of the relevant portion of the server DB, and the UI always reads only from the local DB.
The read path and write path are separated. Reads always go local PGlite → UI, and writes go UI → Backend API → Server Postgres → Electric stream → PGlite. Electric is a read-only, unidirectional sync engine in this flow. Writing directly to PGlite from the client does not propagate those changes to the server.
ElectricSQL Shapes
ElectricSQL is a Postgres sync engine that streams server PostgreSQL changes to clients in real time. With the v1.0 GA release in March 2025, its core API stabilized, and v1.1 introduced a new storage engine that significantly improved server-side write throughput compared to v1.0.
A Shape is the primitive in ElectricSQL for declaring a subset of data to synchronize. You specify "give me only these columns from rows matching this condition in this table," and Electric filters the PostgreSQL logical replication stream by Shape and emits it as an HTTP stream.
PGlite: Browser-Embedded PostgreSQL
PGlite is PostgreSQL compiled to WebAssembly and packaged as a TypeScript library. It runs in the browser, Node.js, Bun, and Deno without any separate OS packages or external processes. It supports in-memory or persistent storage backed by IndexedDB (browser) / filesystem (Node), and is distributed as a bundle measuring a few MB compressed. Exact bundle sizes vary by version — check the official release notes.
import { PGlite } from '@electric-sql/pglite'
import { live } from '@electric-sql/pglite/live'
const db = new PGlite('idb://my-app-db', {
extensions: { live },
})
const result = await db.live.query(
'SELECT * FROM issues ORDER BY created_at DESC',
[],
(updatedResult) => {
console.log('Data changed:', updatedResult.rows)
}
)live.query is particularly useful. The callback fires automatically whenever data inside PGlite changes, so wiring it to a React component makes the UI reactive without a separate state management layer. The database itself becomes the state.
Two Integration Patterns: A vs B
There are two primary ways to connect ElectricSQL with React, and confusing the two creates unnecessary complexity.
Pattern A: useShape → React State (no PGlite needed)
Use the useShape hook from @electric-sql/react to receive Shape data directly as React state.
import { useShape } from '@electric-sql/react'
function IssueList({ userId }: { userId: string }) {
const { isLoading, data } = useShape<{ id: string; title: string; status: string }>({
url: `${ELECTRIC_URL}/v1/shape`,
params: {
table: 'issues',
where: `user_id = '${userId}'`,
},
})
if (isLoading) return <div>Loading...</div>
return <ul>{data.map(issue => <li key={issue.id}>{issue.title}</li>)}</ul>
}Setup is simple, but there is no offline persistence, and you cannot run SQL JOINs or aggregate queries. Suitable for simple screens that only need real-time reads.
Pattern B: syncShapeToTable → PGlite → useLiveQuery (the primary architecture of this article)
Use @electric-sql/pglite-sync to sync the Shape stream into a PGlite table in the browser, and query it with useLiveQuery from @electric-sql/pglite-react. If you need offline persistence, complex SQL queries, or an optimistic write pattern, choose Pattern B. The rest of this article is based on Pattern B.
Key Package Summary
| Package | Role | Pattern |
|---|---|---|
@electric-sql/react |
useShape hook |
A |
@electric-sql/pglite |
WASM PostgreSQL for browser/Node | B |
@electric-sql/pglite-sync |
Electric Shape → PGlite sync | B |
@electric-sql/pglite-react |
useLiveQuery hook |
B |
@electric-sql/pglite/worker |
Worker wrapper to prevent UI blocking | B |
@electric-sql/pglite/live |
Auto re-render on DB changes via live query | B |
@electric-sql/react and @electric-sql/pglite-react have similar names but are different packages. In Pattern B, you must install @electric-sql/pglite-react.
Practical Application
Scenario: Offline-Capable Issue Tracker
Imagine building a Linear-style issue tracker. Users must be able to create issues and change their status while offline, and the app must automatically sync with the server when back online. ElectricSQL's official demo, Linearlite, uses exactly this pattern.
Security Boundary: Shape Filters Are Not Authorization
There is something you must address before writing any code.
params: {
table: 'issues',
where: `user_id = '${userId}'`,
}Some describe this where clause as an "SQL injection risk," but that is not quite right. Electric's where is not SQL executed in the browser — it is a filter expression evaluated by the Electric server. The real problem lies elsewhere.
A structure where the client directly declares the where clause is not an authorization boundary. A malicious client can simply send where: "1=1" or another user's user_id directly to the Electric server. The where clause is a query optimization hint, not a security boundary.
In production implementations, you must place an authentication proxy in front of Electric.
Client → Auth Middleware / Gatekeeper → Electric ServerTwo approaches:
- Use Electric JWT auth: Configure the Electric server with JWT authentication so that middleware can enforce
whereconditions on the server side based on user context. - Reverse proxy: Have Nginx or server middleware validate the JWT from the request, then fix the
whereclause to the verified user ID before forwarding to Electric.
A client-declared where is fine at the prototype stage, but before deploying to production you must move this boundary to the server side.
Full Setup Code
Step 1: Install Packages
npm install @electric-sql/pglite @electric-sql/pglite-sync @electric-sql/pglite-reactStep 2: Write the Worker File
PGlite is WASM-based, so running it on the main thread blocks the UI. Always run it in a Worker. Importantly, extensions must be registered on the PGlite instance inside the Worker. The PGliteWorker on the main thread acts only as a proxy that delegates requests to the Worker.
// pglite-worker.js
import { PGlite } from '@electric-sql/pglite'
import { live } from '@electric-sql/pglite/live'
import { electricSync } from '@electric-sql/pglite-sync'
import { registerWorkerMessageHandler } from '@electric-sql/pglite/worker'
const db = new PGlite('idb://issues-app', {
extensions: {
live,
electric: electricSync(),
},
})
registerWorkerMessageHandler(db)// lib/db.ts
import { PGliteWorker } from '@electric-sql/pglite/worker'
export const db = new PGliteWorker(
new Worker(new URL('./pglite-worker.js', import.meta.url), { type: 'module' })
)Step 3: Initialize DB and Sync Shape
Table design is critical for implementing optimistic writes correctly. If you INSERT optimistic state directly into the issues table managed by syncShapeToTable, the next Electric sync event may delete or overwrite that row. Instead, use the pattern of storing optimistic state in a separate issues_local table and merging via a View.
// lib/db.ts (continued)
export async function initDb(userId: string) {
await db.exec(`
-- Server sync table owned by Electric
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'open',
user_id TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Purely local: holds optimistic state
CREATE TABLE IF NOT EXISTS issues_local (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'open',
user_id TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- View merging both tables: UI reads only from this view
CREATE OR REPLACE VIEW issues_merged AS
SELECT id, title, description, status, user_id, created_at, false AS is_local
FROM issues
UNION ALL
SELECT id, title, description, status, user_id, created_at, true AS is_local
FROM issues_local
WHERE id NOT IN (SELECT id FROM issues);
`)
-- Electric manages only the issues table; issues_local is untouched.
await db.electric.syncShapeToTable({
shape: {
url: `${process.env.ELECTRIC_URL}/v1/shape`,
params: {
table: 'issues',
where: `user_id = '${userId}'`,
},
},
table: 'issues',
primaryKey: ['id'],
})
}The issues_merged view shows only rows from issues_local that are not yet in issues. The moment confirmed server data arrives in issues, the corresponding row in issues_local is automatically hidden from the view.
Step 4: Connect React Components with Live Query
// hooks/useIssues.ts
import { useLiveQuery } from '@electric-sql/pglite-react'
import { db } from '../lib/db'
export function useIssues(statusFilter?: string) {
return useLiveQuery(
statusFilter
? `SELECT * FROM issues_merged WHERE status = $1 ORDER BY created_at DESC`
: `SELECT * FROM issues_merged ORDER BY created_at DESC`,
statusFilter ? [statusFilter] : [],
db
)
}Step 5: Implement the Offline Queue and Optimistic Writes
// lib/offline-queue.ts
type QueueEntry = {
id: string
type: string
payload: unknown
timestamp: number
}
const QUEUE_KEY = 'offline-queue'
export const offlineQueue = {
enqueue(entry: Omit<QueueEntry, 'id' | 'timestamp'>) {
const queue = this.getAll()
queue.push({ ...entry, id: crypto.randomUUID(), timestamp: Date.now() })
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue))
},
getAll(): QueueEntry[] {
try {
return JSON.parse(localStorage.getItem(QUEUE_KEY) ?? '[]')
} catch {
return []
}
},
remove(id: string) {
const filtered = this.getAll().filter(e => e.id !== id)
localStorage.setItem(QUEUE_KEY, JSON.stringify(filtered))
},
}// hooks/useCreateIssue.ts
import { db } from '../lib/db'
import { offlineQueue } from '../lib/offline-queue'
export function useCreateIssue() {
const createIssue = async (title: string, description: string, userId: string) => {
const optimisticId = crypto.randomUUID()
// Optimistic state: write to issues_local, not the Electric-managed issues table
await db.query(
`INSERT INTO issues_local (id, title, description, status, user_id)
VALUES ($1, $2, $3, 'open', $4)`,
[optimisticId, title, description, userId]
)
try {
const response = await fetch('/api/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: optimisticId, title, description }),
})
if (!response.ok) {
await db.query(`DELETE FROM issues_local WHERE id = $1`, [optimisticId])
throw new Error('Failed to create issue.')
}
// Server write succeeded: remove optimistic state
// Once Electric syncs server data into the issues table, the view automatically shows the server version
await db.query(`DELETE FROM issues_local WHERE id = $1`, [optimisticId])
} catch (error) {
if (!navigator.onLine) {
// Offline: keep optimistic state, enqueue for retry
offlineQueue.enqueue({
type: 'create_issue',
payload: { optimisticId, title, description, userId },
})
} else {
// Online but server error: roll back
await db.query(`DELETE FROM issues_local WHERE id = $1`, [optimisticId])
throw error
}
}
}
return { createIssue }
}A sequence diagram makes the full optimistic write flow easier to understand.
When offline, the API request fails but the optimistic state in issues_local is preserved, so the UI keeps working. On reconnection, the offline queue is processed to retry the API request, and as Electric streams in the confirmed data, the view naturally transitions to the server version.
// components/IssueList.tsx
import { useIssues } from '../hooks/useIssues'
import { useCreateIssue } from '../hooks/useCreateIssue'
export function IssueList({ userId }: { userId: string }) {
const { rows: issues } = useIssues()
const { createIssue } = useCreateIssue()
return (
<div>
<button onClick={() => createIssue('New Issue', 'Description...', userId)}>
Add Issue
</button>
<ul>
{issues?.map(issue => (
<li key={issue.id} style={{ opacity: issue.is_local ? 0.6 : 1 }}>
{issue.title} — <span>{issue.status}</span>
{issue.is_local && <span> (Syncing...)</span>}
</li>
))}
</ul>
</div>
)
}The is_local flag lets you visually distinguish items not yet confirmed by the server.
Multi-Tab Support
PGlite supports only a single connection by default. If two browser tabs open the same IndexedDB simultaneously, they will conflict. The regular Worker pattern introduced earlier does not solve this problem — each tab creates its own separate Worker, and each Worker opens the same IndexedDB independently.
In a multi-tab environment, you must use a SharedWorker so that multiple tabs share a single PGlite instance.
// shared-worker.js
import { PGlite } from '@electric-sql/pglite'
import { live } from '@electric-sql/pglite/live'
import { electricSync } from '@electric-sql/pglite-sync'
import { registerWorkerMessageHandler } from '@electric-sql/pglite/worker'
const db = new PGlite('idb://issues-app', {
extensions: {
live,
electric: electricSync(),
},
})
registerWorkerMessageHandler(db)// lib/db.ts (multi-tab environment)
import { PGliteWorker } from '@electric-sql/pglite/worker'
export const db = new PGliteWorker(
new SharedWorker(new URL('./shared-worker.js', import.meta.url), { type: 'module' })
)The Worker file's internal code is identical, but on the main thread you use new SharedWorker() instead of new Worker(). A SharedWorker shares a single Worker instance across multiple tabs of the same origin. For single-tab apps a regular Worker is sufficient; switch to SharedWorker only when multi-tab support is needed. See the PGlite official docs: Multi-tab Worker for detailed configuration.
Shape Scope Decision Flow
When unsure what data to group into a Shape, the following flow helps.
IndexedDB storage has capacity limits depending on browser policy. Keeping Shape scope to the minimum necessary data matters for both initial sync speed and storage efficiency.
Pros and Cons
Traditional Approach vs Local-First
| Item | Traditional Client-Server | ElectricSQL + PGlite |
|---|---|---|
| Read latency | Network round-trip time | Effectively 0 for local queries after initial sync |
| Offline reads | Not possible or requires separate cache | Fully supported |
| Offline writes | Not possible or requires manual queue | Immediate via optimistic state |
| Real-time multi-user | Requires separate WebSocket server | Electric handles fan-out |
| State management | Requires Redux, Zustand, etc. | The DB itself is the state |
| Browser SQL | Not possible | Full Postgres SQL |
| Conflict resolution | Custom implementation required | Last-Write-Wins by default |
"Effectively 0 read latency" comes with a caveat. On first app load, time is needed to download the PGlite WASM bundle and complete the initial Shape sync — during this interval the UI is in a loading state. On subsequent visits, data cached in IndexedDB renders immediately, and only changes are incrementally synced.
Comparison with Competing Technologies
| ElectricSQL + PGlite | PowerSync | Zero (Rocicorp) | |
|---|---|---|---|
| Server DB | Postgres only | Postgres / MySQL / MongoDB | Postgres |
| Sync direction | Unidirectional read + separate write API | Bidirectional | Bidirectional |
| Mobile SDK | Limited | Mature | Web-focused |
| Browser DB | PGlite (Postgres) | SQLite (wa-sqlite) | Custom cache |
| Open source | Apache 2.0 | BSL | Server closed, client npm public |
| Conflict resolution | LWW default, CRDT separate impl. | LWW default, CRDT separate impl. | Server-authoritative |
Zero (Rocicorp) has closed server code, but its client package is publicly available on npm. PowerSync defaults to Last-Write-Wins for conflict resolution and requires a separate CRDT implementation — similar to ElectricSQL in this regard. For Postgres-based web apps that prefer open source and self-hosting, the ElectricSQL + PGlite combination offers the strongest advantages.
Common Pitfalls in Production
Pitfall 1: "Electric will handle writes too"
ElectricSQL is a read-only, unidirectional sync engine. Writing directly to PGlite from the client does not propagate those changes to the server. Writes must go through a separate REST API or GraphQL mutation to reach server Postgres, after which Electric streams that change back to clients.
Pitfall 2: Writing optimistic state directly to Electric-managed tables
// Problematic pattern: INSERT directly into a table managed by syncShapeToTable
await db.query(`INSERT INTO issues (id, title, ...) VALUES (...)`)
// The next Electric sync event may delete or overwrite this row
// Correct pattern: store optimistic state in a separate local table
await db.query(`INSERT INTO issues_local (id, title, ...) VALUES (...)`)
// Merge into UI via the issues_merged viewPitfall 3: Defining Shape scope too broadly
// Pattern to avoid: subscribing to the entire table
params: { table: 'issues' }
// Recommended pattern: only the current user's data, only needed columns
params: {
table: 'issues',
where: `user_id = '${userId}'`,
columns: ['id', 'title', 'status'],
}The broader the Shape scope, the more IndexedDB capacity is used and the longer the initial sync takes.
Pitfall 4: Opening the DB from multiple tabs
Calling new PGlite('idb://my-db') independently in each tab opens the same IndexedDB with multiple connections, causing conflicts. Use the SharedWorker pattern to share a single connection.
Pitfall 5: Using Last-Write-Wins as-is in collaborative editing apps
Electric's default conflict resolution strategy is Last-Write-Wins. This is sufficient for simple CRUD apps, but in collaborative apps where multiple users edit the same field simultaneously, data loss can occur.
In such cases, integrating a CRDT (Conflict-free Replicated Data Types) library is an option.
- Yjs: Document-editing focused. Serialize Yjs documents into PGlite columns and resolve conflicts with Y.js's merge algorithm. Well-suited for rich text or collaborative editors.
- Automerge: Well-suited for structured JSON data. Store Automerge documents in PGlite columns and use a pattern of server-side merging followed by distribution via Electric.
Both libraries operate independently of PGlite, so PGlite serves as the storage layer while the CRDT library serves as the conflict resolution layer. Good starting points are the Materialized View podcast episode on ElectricSQL + CRDTs and the official documentation for each library.
Closing Thoughts
The ElectricSQL Shapes + PGlite combination delivers three core values. First, after initial sync completes, the UI reads from the local DB, so read latency is effectively zero. Second, optimistic writes keep the app fully functional even when offline. Third, Electric fans out server changes to all Shape subscribers, enabling real-time multi-user features without a separate WebSocket server.
There are also things to keep in mind at all times. Electric is a read-only, unidirectional sync — writes must go through a separate API. Optimistic state must not be written directly to Electric-managed tables; keep it in a separate local table. Shape filters are not authorization boundaries, so production deployments must always include an authentication proxy.
3 steps you can start with right now:
-
Clone and run the Linearlite example app — Clone Linearlite from the official ElectricSQL GitHub to see firsthand how this pattern works in a real-scale app.
-
Connect your existing Postgres to Electric Cloud — Electric Cloud, which launched in public beta in April 2025, lets you connect Electric to your existing Postgres without self-hosting. Cloud is the fastest path for proof-of-concept work.
-
Apply Pattern B to one read-heavy screen in your existing app — No need to rewrite the whole app; apply
syncShapeToTableanduseLiveQueryto just one slow-loading list screen. After initial sync, you'll immediately experience reads with no network round-trips.
References
- Electric 1.0 Released — ElectricSQL Official Blog
- Shapes - Guide — ElectricSQL Official Docs
- Writes - Guide — ElectricSQL Official Docs
- Sync using Electric — PGlite Official Docs
- Getting started with PGlite — PGlite Official Docs
- Multi-tab Worker — PGlite Official Docs
- GitHub: electric-sql/pglite
- GitHub: electric-sql/electric
- Local-first with your existing API — ElectricSQL Blog
- Electric Cloud Public Beta Release — ElectricSQL Blog
- Linearlite Example App — GitHub
- Write Patterns for ElectricSQL — QueryPlane
- ElectricSQL, PGLite, CRDTs, and Elixir with James Arthur — Materialized View
- Comparing local-first frameworks and approaches — Neon Blog
- Using ElectricSQL to build a local-first application — LogRocket Blog
- Yjs CRDT Library
- Automerge CRDT Library