Local-First Sync with PGLite and ElectricSQL: A Deep Dive
For the last decade, web development has been dominated by the request-response cycle. We build APIs, manage complex caching layers, and spend an inordinate amount of time engineering 'optimistic UI' updates to hide the fact that our data lives thousands of miles away from the user. But as user expectations for performance and offline capability rise, the traditional thin-client architecture is showing its age.
Enter the 'Local-First' paradigm. Instead of treating the browser as a temporary view into a remote database, we treat it as a primary data node. By replicating a subset of our Postgres database directly into the browser, we can build applications that are instantly responsive, work perfectly offline, and simplify state management.
In this guide, we’ll explore how to implement this using two groundbreaking tools: PGLite, a WASM-based Postgres build for the browser, and ElectricSQL, the synchronization layer that bridges the gap between your server-side Postgres and the client.
The Shift from API Calls to Data Replication
In a traditional SPA, when a user clicks 'Save,' you send a POST request, wait for a 200 OK, and then update the local state. If the network is flaky, the UI hangs or shows a spinner. Local-first flips this. The user writes to a local database (PGLite) instantly. A background process (ElectricSQL) then handles the synchronization with the server when connectivity is available.
This isn't just about 'offline mode.' It’s about latency. When your data is local, 'queries' take microseconds, not milliseconds. You no longer need Redux or TanStack Query to manage server state; your local database is your state.
The Core Components
PGLite: Postgres in a WASM Wrapper
Until recently, if you wanted a database in the browser, you were limited to IndexedDB or SQLite via WASM. While SQLite is excellent, it creates a 'dialect mismatch' if your backend is Postgres. You have to translate schemas, handle different data types, and manage two different SQL flavors.
PGLite changes the game. It is a full Postgres build packaged as a WASM module. It’s small (~3MB gzipped), supports many Postgres extensions, and most importantly, it is Postgres. You get the same triggers, constraints, and JSONB support you use on the server, running directly in a browser tab or a Web Worker.
ElectricSQL: The Sync Engine
Replicating data is hard. You have to handle partial replication (you can’t download a 1TB database to a phone), conflict resolution, and security.
ElectricSQL acts as a synchronization middleware. It plugs into your Postgres server’s logical replication stream and exposes a 'Shapes' API. A 'Shape' is a subset of your data—defined by tables and filters—that a client can subscribe to. Electric handles the heavy lifting of streaming changes back and forth, ensuring that the local PGLite instance stays in sync with the global Postgres source of truth.
Architecting the Sync: Understanding 'Shapes'
The most critical concept in this architecture is the Shape. You rarely want to sync your entire database to every user. Instead, you define shapes based on the user's context.
For example, in a project management app, a user might sync:
- All rows in the
projectstable whereuser_id = '123'. - All rows in the
taskstable linked to those projects.
ElectricSQL ensures that as soon as a new task is added to the server-side Postgres (perhaps by a teammate), it is automatically pushed to the user's PGLite instance. Conversely, any local change the user makes is captured by Electric and synced back to the server.
Implementation Walkthrough
Let’s look at how we actually wire this together. We’ll assume you have a Postgres database running and the Electric Sync Service sits between your DB and your frontend.
1. Setting up PGLite
First, we initialize PGLite in the browser. We can choose to persist data in IndexedDB so it survives page refreshes.
import { PGLite } from '@electric-sql/pglite'; import { idb } from '@electric-sql/pglite/idb'; // Initialize PGLite with IndexedDB persistence const db = new PGLite({ dataDir: 'idb://my-app-db' }); // You can now run standard Postgres queries immediately await db.query("CREATE TABLE IF NOT EXISTS items (id UUID PRIMARY KEY, content TEXT);");
2. Connecting to Electric
Next, we use the Electric client to define a shape and start the sync process. The Electric client integrates directly with PGLite.
import { core } from '@electric-sql/client'; // Define the shape we want to sync const shape = { table: 'items', where: "user_id = 'user_abc'" }; // Start the sync process const sync = await electric.sync(shape); // Electric will now populate the 'items' table in PGLite // and keep it updated in real-time.
3. Reactive UI with SQL
The beauty of this setup is how it interacts with the UI. Instead of complex useEffect hooks fetching data, you subscribe to the database. Many frameworks now have bindings that make this seamless.
// Example of a reactive hook (pseudo-code) const { rows } = useLiveQuery("SELECT * FROM items ORDER BY created_at DESC"); return ( <ul> {rows.map(item => <li key={item.id}>{item.content}</li>)} </ul> );
When data changes—whether locally or via a sync from the server—the query re-runs, and the UI updates instantly. No loaders, no flickering.
Handling Conflicts and Consistency
One of the biggest fears in local-first development is the 'split-brain' scenario: two users edit the same record while offline.
ElectricSQL uses Causal Consistency. It tracks the dependencies of operations. For most web applications, the default 'Last Write Wins' (LWW) approach is sufficient, but because we are using Postgres on both ends, we can also leverage more sophisticated techniques.
Since Electric uses the Postgres logical replication protocol, it maintains a strict order of operations. When a client reconnects, it sends its local log of changes. Electric integrates these changes into the main Postgres instance, resolves conflicts based on timestamps or defined rules, and then broadcasts the 'final' state back to all clients.
Security and Authorization
In a local-first world, you cannot rely on an API layer to filter every row on every request. Security must be handled at the sync level.
ElectricSQL integrates with your existing authentication provider (like Auth0, Clerk, or Supabase). When a client requests a shape, Electric validates the user's JWT and ensures they only have permission to subscribe to the data they are requesting. This moves the 'Authorization' logic from your individual API endpoints to a centralized sync policy, often defined in SQL or a configuration file.
Performance Considerations
While PGLite is incredibly efficient, there are a few things to keep in mind:
- Initial Sync Latency: The first time a user opens your app, they have to download the initial data set (the 'bootstrap'). Keep your shapes tight to minimize this.
- Storage Limits: Browsers impose limits on IndexedDB (usually a percentage of free disk space). While usually generous, it’s not infinite. Monitor your local database size.
- WASM Overhead: PGLite runs in a Web Worker to keep the main thread smooth, but it still consumes memory. For mobile devices with limited RAM, be mindful of how many concurrent PGLite instances or complex queries you run.
Real-World Use Case: A Collaborative Task Manager
Imagine a Trello-like application. In a traditional setup, moving a card involves:
- Updating local state (optimistic UI).
- Sending a PUT request to
/cards/:id. - Handling a potential error and rolling back the UI.
- Other users polling or using WebSockets to see the move.
With PGLite and ElectricSQL:
- The user runs
UPDATE cards SET column = 'done' WHERE id = 123on their local PGLite. - The UI updates instantly because it's observing the local DB.
- ElectricSQL sees the change in the local transaction log and streams it to the server.
- The server updates the master Postgres DB.
- Teammates' Electric clients see the update in the replication stream and update their local PGLite instances.
- Their UIs update automatically.
The developer writes standard SQL and doesn't worry about the 'plumbing' of WebSockets or conflict resolution logic.
Conclusion: The Actionable Path Forward
The transition to local-first is more than a trend; it's a structural improvement in how we deliver software. By moving the database to the edge, we eliminate the primary bottleneck of web applications: the network.
If you're looking to implement this today, here is your roadmap:
- Audit your data requirements: Identify which parts of your application benefit most from instant responsiveness and offline support.
- Start with PGLite: Replace a small piece of complex client-side state (like a multi-step form or a complex filter) with a local PGLite instance. Get a feel for querying SQL in the browser.
- Deploy ElectricSQL: Set up an Electric Sync Service instance against a development Postgres database.
- Define your first Shape: Sync a single table and observe the replication in action.
- Simplify your frontend: Remove the loading states and the complex 'fetch-and-cache' logic, replacing them with live SQL queries against your local node.
By adopting PGLite and ElectricSQL, you aren't just building a faster app; you're building a more robust architecture that treats data as a first-class citizen on the client, exactly where the user is.