Local-First State Sync: Building with ElectricSQL and PGLite
The standard architectural pattern for web applications has remained largely unchanged for a decade: a thin client communicates with a remote server via REST or GraphQL, which in turn talks to a centralized database. While this model is familiar, it introduces inherent friction: latency, 'spinner hell,' and a total dependency on network availability.
As users demand more responsive, 'instant' experiences, the industry is shifting toward a Local-First paradigm. In this model, the primary data source is local to the device, and synchronization with the server happens asynchronously in the background. This article explores how to implement this architecture using two transformative tools: ElectricSQL and PGLite.
The Architecture of Local-First
Local-first is not merely 'offline mode'—it is a fundamental shift in where the 'source of truth' resides during a user session. Instead of the client being a view of the server's data, the client maintains its own database.
To make this work at scale, we need three core components:
- A Local Database: A robust, queryable engine running in the browser (WASM).
- A Sync Engine: A service that manages the bidirectional flow of data between the local DB and the cloud DB.
- Reactivity: A mechanism to automatically update the UI when the local database changes.
PGLite: Postgres in the Browser
Historically, local storage meant using localStorage, IndexedDB, or perhaps SQLite via WASM. While SQLite is excellent, it creates a mismatch if your backend is running PostgreSQL. You end up managing two different dialects and behaviors.
PGLite changes this. It is a fully functional, single-file WASM build of PostgreSQL. It allows you to run a real Postgres instance inside the browser, a Web Worker, or Node.js.
Why PGLite?
- Full Postgres Syntax: Use Common Table Expressions (CTEs), JSONB, and advanced window functions directly on the client.
- Low Footprint: Despite being a full Postgres build, it is highly optimized for the browser environment.
- Persistence: It can persist data to IndexedDB, ensuring that the database survives page refreshes.
import { PGLite } from '@electric-sql/pglite'; const db = new PGLite('idb://my-database'); await db.query("CREATE TABLE IF NOT EXISTS todos (id SERIAL PRIMARY KEY, task TEXT, done BOOLEAN);"); await db.query("INSERT INTO todos (task, done) VALUES ($1, $2);", ['Learn ElectricSQL', false]);
ElectricSQL: The Sync Layer
Having a local database is only half the battle. You need a way to sync that data with a central authority. This is where ElectricSQL comes in.
ElectricSQL is a sync engine that sits between your primary Postgres database and your local PGLite instances. It uses Postgres's logical replication (Change Data Capture or CDC) to stream changes back and forth.
How it Works
ElectricSQL works on the concept of Shapes. A Shape is a subset of your database—specific tables and filtered rows—that a client subscribes to. When data in that Shape changes on the server, ElectricSQL pushes those changes to the client. When the client makes a local change, ElectricSQL captures it and propagates it back to the server.
Implementing the Sync Flow
To build a local-first application with these tools, we follow a specific implementation path: setting up the server-side Postgres, configuring the Electric sync service, and connecting the PGLite client.
1. Defining the Schema and Permissions
ElectricSQL relies on your existing Postgres schema. You must enable logical replication and 'electrify' the tables you want to sync.
-- On your server-side Postgres ALTER TABLE todos ENABLE ELECTRIC;
Security is handled via Row-Level Security (RLS). Because the client has a full copy of the data in their 'Shape', it is critical that the Shape only contains data the user is authorized to see.
2. Initializing the Client
In your frontend application (e.g., a React or Vue app), you initialize the Electric client by providing the PGLite instance and the connection URL to your Electric sync service.
import { electrify } from 'electric-sql/pglite'; import { PGLite } from '@electric-sql/pglite'; const pg = new PGLite(); const config = { url: 'https://your-electric-service-url.com', }; // This 'electrifies' the local PGLite instance const electric = await electrify(pg, schema, config);
3. Subscribing to Shapes
Instead of fetching data via an API call, you 'sync' a Shape. Once synced, the data resides in PGLite, and any future changes from other users will be streamed into your local DB automatically.
const shape = await electric.db.todos.sync(); await shape.synced; // Wait for initial data load
The Reactive UI Pattern
One of the most powerful aspects of this stack is the elimination of complex state management libraries for server data. You don't need react-query or RTK Query to manage cache invalidation. Your UI becomes a direct reflection of your local database.
In a React context, you can use hooks provided by the Electric library to 'watch' a query:
import { useLiveQuery } from 'electric-sql/react'; const TodoList = () => { const { results } = useLiveQuery(electric.db.todos.liveMany()); return ( <ul> {results.map(todo => ( <li key={todo.id}>{todo.task}</li> ))} </ul> ); };
When a user clicks a checkbox, you simply run a standard SQL UPDATE against the local PGLite instance. The useLiveQuery hook detects the change and rerenders the UI instantly. Behind the scenes, ElectricSQL handles the heavy lifting of sending that update to the server and resolving any conflicts.
Handling Conflicts with CRDTs
In a distributed system where multiple users can edit the same data while offline, conflicts are inevitable. ElectricSQL handles this using Conflict-free Replicated Data Types (CRDTs) and a 'Last Write Wins' (LWW) resolution policy by default.
Because ElectricSQL operates at the database layer, it understands the causal order of operations. If User A updates a task title while offline and User B deletes that task, ElectricSQL uses its internal metadata to ensure all clients eventually converge on the same state without manual intervention.
Practical Considerations and Trade-offs
While the Local-First approach with PGLite and ElectricSQL is powerful, it is not a silver bullet. Senior engineers must weigh the following:
Bundle Size
Including a WASM Postgres engine (PGLite) adds weight to your initial bundle (roughly 3MB - 4MB compressed). For a lightweight landing page, this is overkill. For a complex SaaS tool (like Linear, Notion, or an ERP), the trade-off is easily justified by the performance gains.
Data Volume
You cannot sync a multi-terabyte database to a browser. You must be strategic with your 'Shapes'. Use filters to ensure users only download the data they need for their current context (e.g., only 'active' projects or 'recent' messages).
Security
Traditional APIs act as a gatekeeper. In a local-first world, your Postgres RLS policies are your API's security logic. This requires a shift in mindset: your database schema must be designed with strict multi-tenancy and access control at the row level.
Real-World Example: Collaborative Project Management
Imagine building a collaborative Trello-like board.
- The Old Way: Every time a card is moved, you send a POST request. You show a loading state. If the network fails, you show an error and roll back the UI. If another user moves a card, you wait for a WebSocket message or a poll to refresh the list.
- The Local-First Way: The user moves the card. The UI updates at 60fps because it's just a local DB write. ElectricSQL pushes the change to the server in the background. If the user closes their laptop and goes to a cafe, they keep working. When they reconnect, all their moves are synced. Other users see the cards move in real-time as the sync engine streams changes into their local PGLite instances.
Conclusion
The combination of ElectricSQL and PGLite represents a significant milestone in web architecture. By bringing a reactive, full-featured Postgres engine to the client and providing a seamless synchronization layer, we can finally move away from the 'request-response' bottleneck.
Actionable Next Steps:
- Audit your current application: Identify features where high latency or offline issues cause the most user frustration (e.g., forms, editors, dashboards).
- Prototype with PGLite: Replace a small piece of IndexedDB logic or a complex client-side filter with a PGLite query to test the performance.
- Evaluate your Schema: Ensure your Postgres server is configured for logical replication and that your RLS policies are robust enough to support direct client synchronization.
Local-first is no longer a niche requirement for specialized apps; it is the next evolution of the high-performance web.