A local-first todo app that demonstrates real-time sync between browser tabs using PGLite (Postgres in the browser via WASM) and ElectricSQL (real-time sync engine).
[Browser Tab 1] [Browser Tab 2]
PGLite (WASM) PGLite (WASM)
| |
v v
Electric Client Electric Client
| |
+---- HTTP SSE ----------+
|
Electric Sync Service
|
Server Postgres
^
|
Express API (writes)
- Reads: come from local PGLite (instant, no network roundtrip)
- Writes: go through the Express API → Postgres
- Sync: Electric streams Postgres changes to all browser tabs via HTTP SSE
| Layer | Tech |
|---|---|
| Frontend | Vite + React + TypeScript |
| Local DB | PGLite (@electric-sql/pglite) |
| Sync | ElectricSQL (@electric-sql/pglite-sync) |
| Styling | Tailwind CSS |
| API server | Express + node-postgres |
| DB | Postgres 16 |
| Infrastructure | Docker Compose |
docker compose up -dThis starts:
- Postgres 16 on port
5432(withwal_level=logicalfor CDC) - Electric sync service on port
3000
Wait a few seconds for both services to be healthy.
Frontend:
npm installAPI server:
cd server && npm install && cd ..Copy .env.example to .env.local in the root if you need to change defaults:
cp .env.example .env.localDefault values:
VITE_ELECTRIC_URL=http://localhost:3000— Electric sync serviceVITE_API_URL=http://localhost:3001— Express API server
cd server && npm run devIn a new terminal:
npm run devThe app runs at http://localhost:5173
- Open http://localhost:5173 in two browser tabs
- Add a todo in Tab 1 → it appears in Tab 2 within ~1 second
- Toggle or delete in either tab → syncs to the other
Changes are also persisted in PGLite (IndexedDB) — reload the page and todos are still there without a network request.
pglite-electric-poc/
├── src/
│ ├── components/
│ │ ├── AddTodo.tsx # Form to create todos (calls API)
│ │ ├── TodoItem.tsx # Single todo row (toggle/delete via API)
│ │ └── TodoList.tsx # Live-queried list from PGLite
│ ├── db.ts # PGLite setup + Electric sync init
│ ├── App.tsx
│ ├── main.tsx
│ └── index.css
├── server/
│ ├── index.ts # Express API (CRUD → Postgres)
│ ├── migrations/
│ │ └── init.sql # Schema (run on first Postgres start)
│ └── package.json
├── docker-compose.yml
├── vite.config.ts
└── package.json
- PGLite creates a full Postgres instance in the browser (via WASM), persisted in IndexedDB
electricSyncextension connects to the Electric service and syncs thetodosshape into the local PGLite tableuseLiveQueryhook re-renders the UI whenever PGLite data changes- Writes hit the Express API → Postgres → Electric detects the WAL changes → pushes to all connected clients → PGLite updates → React re-renders