Local-First Mastery: Syncing Postgres to the Browser with PGLite
The architecture of web applications is undergoing a fundamental shift. For decades, we have operated under the 'Thin Client' model: the browser is a window, the server is the brain, and every user interaction is a request-response cycle mediated by an API. While this works, it introduces inherent latency, 'loading' spinners, and total failure in offline scenarios.
'Local-first' development flips this script. By treating the local device as the primary data source and the cloud as a synchronization and backup layer, we can build applications that are instantly responsive, work offline, and handle multi-user collaboration gracefully. Today, we are exploring the cutting edge of this movement: using PGLite (Postgres in WASM) and ElectricSQL to bring true Postgres-to-browser replication to life.
The Evolution of the Local Data Layer
Historically, local storage in the browser was limited to simple Key-Value stores like localStorage or the more complex but often cumbersome IndexedDB. When developers wanted a relational experience, they turned to SQLite compiled to WebAssembly (WASM).
However, there was always a mismatch. Most backend stacks rely on PostgreSQL. Using SQLite in the frontend and Postgres in the backend meant maintaining two different SQL dialects, two different schema migration paths, and a complex mapping layer in between.
Enter PGLite: Postgres in the Browser
PGLite is a groundbreaking project from the team at ElectricSQL. It is a build of the actual Postgres source code compiled to WebAssembly, packaged into a lightweight TypeScript library. Unlike other WASM Postgres attempts, PGLite is remarkably small (~3MB gzipped) and starts up in milliseconds.
Because it is real Postgres, you get full-text search, JSONB support, sophisticated window functions, and the same relational integrity you expect on the server. But the real magic happens when you connect this local database to a remote source of truth.
The Sync Engine: Why ElectricSQL?
Having a database in the browser is only half the battle. The harder problem is synchronization. How do you move data between a central Postgres instance and thousands of local PGLite instances without creating a nightmare of conflict resolution and security holes?
ElectricSQL acts as a synchronization layer that sits between your main Postgres database and your frontend application. It leverages Postgres's logical replication slots to stream changes in real-time. When a change happens on the server, ElectricSQL captures it and pushes it to the client. When the client makes a change locally, it is captured by the local PGLite instance and synced back to the server when a connection is available.
Architectural Overview
In a typical PGLite + ElectricSQL stack, the architecture looks like this:
- Primary Database: A standard PostgreSQL instance (e.g., RDS, Supabase, or Neon).
- Electric Sync Service: A middleware service that manages the replication stream.
- Client-side PGLite: The local database running in the user's browser (or mobile app via Capacitor/React Native).
- Electric Client SDK: A library that manages the WebSocket connection between PGLite and the Sync Service.
Implementing a Local-First Task Manager
Let’s look at how we would implement a collaborative task management application using this stack.
1. Defining the Server Schema
First, we define our table in the standard Postgres database. To enable synchronization, we simply tell Electric which tables to 'electrify'.
-- On the server-side Postgres CREATE TABLE projects ( id UUID PRIMARY KEY, name TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); CREATE TABLE tasks ( id UUID PRIMARY KEY, project_id UUID REFERENCES projects(id), title TEXT NOT NULL, is_completed BOOLEAN DEFAULT FALSE, updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Electrify the tables ALTER TABLE projects ENABLE ELECTRIC; ALTER TABLE tasks ENABLE ELECTRIC;
2. Initializing PGLite in the Frontend
In our React or Vue application, we initialize PGLite and connect it to the Electric sync service.
import { PGLite } from '@electric-sql/pglite'; import { electrify } from 'electric-sql/pglite'; // Initialize PGLite const pg = new PGLite(); // Connect to the Electric Sync Service const config = { url: 'https://your-electric-service-url.com', }; const electric = await electrify(pg, config);
3. Syncing Data with 'Shapes'
One of the most powerful concepts in ElectricSQL is Shapes. You don't want to sync the entire production database to every user's phone. Shapes allow you to define a subset of data to replicate based on filters.
// Sync only the tasks belonging to a specific project const shape = await electric.db.tasks.sync({ where: { project_id: 'some-uuid-here' } }); // Wait for initial sync await shape.isReady();
Once the shape is synced, the data resides in the local PGLite instance. Queries are now executed against the local WASM database, meaning they return in microseconds, regardless of network conditions.
Solving the Conflict Problem
In a distributed system where multiple users can edit the same data offline, conflicts are inevitable. ElectricSQL handles this using Causal Integrity and Last-Write-Wins (LWW) at the column level by default.
Because Electric understands the causal relationship between updates, it ensures that all nodes eventually converge on the same state. If User A updates a task title while offline, and User B marks it as completed, Electric will merge these changes correctly so the final state is a completed task with the new title.
Why This Changes the Developer Experience
For a senior engineer, the most compelling reason to adopt this stack isn't just 'offline support'—it's the simplification of the frontend state management.
In a traditional app, you spend a significant amount of time writing code for:
Loadingstates and skeletons.Optimistic UIupdates (and rolling them back on failure).Cache invalidation(is the data in the Redux store stale?).Pollingor managing complex WebSocket listeners for 'real-time' updates.
With PGLite and ElectricSQL, most of this disappears. You treat the local database as your single source of truth. You write a query, and you use a 'live query' hook to bind it to your UI.
// A reactive hook that updates the UI whenever the local DB changes const { results } = useLiveQuery( "SELECT * FROM tasks WHERE project_id = $1 ORDER BY updated_at DESC", [projectId] );
When a change comes in from the server via the sync stream, PGLite updates, the live query triggers, and the React component re-renders. The developer doesn't need to write a single line of code to handle the 'real-time' aspect; it's a side effect of the replication architecture.
Security and Permissions
A common concern with local-first is: "How do I prevent a user from syncing someone else's data?"
ElectricSQL integrates with your existing authentication provider (like Auth0, Clerk, or Supabase Auth). You define Sync Rules (similar to Postgres Row Level Security) that dictate which rows a user is allowed to include in their 'Shapes'. These rules are enforced by the Electric Sync Service, ensuring that even if a user tries to request a shape they don't own, the service will reject the replication request.
Performance Considerations and Limitations
While PGLite is efficient, there are trade-offs to consider:
- Storage Limits: Browsers impose limits on IndexedDB storage (which PGLite uses for persistence). While often generous (hundreds of megabytes), it's not infinite.
- Initial Sync: The first time a user opens the app, they must download the initial 'Shape'. For very large datasets, this can take time, though it is a one-time cost.
- WASM Overhead: While small, the WASM binary still needs to be downloaded and parsed. This is usually negligible for complex SPAs but worth noting for ultra-lightweight sites.
The Path Forward
Local-first isn't just a trend; it's the logical conclusion of the move toward more powerful edge devices. By using PGLite and ElectricSQL, we are finally able to bridge the gap between the robustness of PostgreSQL and the responsiveness of local state.
This stack is particularly well-suited for:
- B2B SaaS: Where users expect high-performance tables and offline 'airplane mode'.
- Field Tools: Apps used in areas with spotty connectivity (construction, logistics).
- Collaborative Editors: Where multiple users need to see each other's changes instantly.
Conclusion
Implementing local-first sync with PGLite and ElectricSQL removes the 'Network' from the critical path of user interaction. By replicating Postgres to the browser, you gain a powerful relational engine on the client, automatic real-time updates, and a significantly simplified state management model.
Actionable next steps:
- Spin up a local ElectricSQL stack using their Docker Compose starter.
- Identify a high-latency part of your current application (e.g., a complex search or a frequently updated dashboard).
- Prototype a PGLite 'Shape' to handle that data locally and observe the impact on perceived performance.