Local-First Web Apps: Building with PGlite and ElectricSQL
For years, web development has been dominated by the request-response cycle. We build an interface, the user performs an action, we show a loading spinner, we wait for the server to acknowledge the write to a remote database, and finally, we update the UI. While this model is familiar, it is fundamentally limited by the speed of light and the reliability of the user's internet connection.
Local-first architecture flips this model on its head. Instead of the remote server being the primary source of truth for the UI, the local device takes that role. The application interacts with a local database, providing instantaneous feedback, while a background process handles the heavy lifting of synchronization.
In this article, we will explore a cutting-edge stack for implementing this pattern: PGlite, a WASM-based Postgres build for the browser, and ElectricSQL, a synchronization layer that provides bidirectional replication between your local PGlite instances and your central Postgres database.
The Problem with Traditional Cloud-First Architectures
In a traditional Single Page Application (SPA), state management is often a battle against latency. We use libraries like TanStack Query or SWR to cache server state, and we implement optimistic updates to hide the fact that we're waiting on a network round-trip. However, optimistic updates are often brittle and complex to implement correctly for every edge case.
Furthermore, offline support is usually an afterthought. Implementing a robust offline mode with localStorage or IndexedDB often requires a complete rewrite of the data fetching logic, and manual conflict resolution is a nightmare.
Local-first development aims to solve these issues by treating the local database as the primary data store. The UI reacts to local database changes, and the synchronization engine ensures that the local state eventually converges with the server state.
Enter PGlite: Postgres in the Browser
Until recently, if you wanted a database in the browser, your options were limited to IndexedDB (which has a clunky API) or SQLite via WASM. While SQLite is excellent, it creates a mismatch if your backend is running Postgres. You have to manage two different SQL dialects, different data types, and different migration strategies.
PGlite changes the game. It is a build of the actual Postgres source code compiled to WebAssembly (WASM), packaged into a clean TypeScript library. It allows you to run a full Postgres instance—complete with relational tables, constraints, and even extensions—directly in the browser's memory or persisted via IndexedDB.
Why PGlite?
- Full Postgres Compatibility: You can use the same schemas, queries, and data types on both the client and the server.
- Low Latency: Queries run against local memory/disk, typically completing in sub-millisecond timeframes.
- Lightweight: Despite being a full Postgres build, it is optimized for the browser and has a relatively small footprint (~3MB compressed).
- Reactivity: PGlite supports triggers and notifications, allowing your UI to react to data changes automatically.
The Missing Link: ElectricSQL
Running a database in the browser is only half the battle. The real challenge is synchronization. How do you move data between thousands of local PGlite instances and a central Postgres server without losing data or creating impossible conflicts?
ElectricSQL is a sync layer designed specifically for this purpose. It uses Postgres's logical replication features (Change Data Capture or CDC) to stream changes between the server and the client.
ElectricSQL provides a "Sync Service" that sits between your Postgres database and your PGlite-powered frontend. It manages the complexities of:
- Partial Sync: You don't want to download your entire multi-terabyte production database to a user's phone. Electric allows you to define "Shapes"—subsets of the data that the user is authorized to see and needs for their current context.
- Conflict Resolution: It uses Causally Consistent Hybrid Logical Clocks to ensure that even if users make concurrent edits while offline, the state will eventually converge across all devices.
- Active Integrity: It ensures that constraints defined in your Postgres schema are respected, even during the sync process.
Architectural Overview
In a PGlite + ElectricSQL architecture, the flow looks like this:
- The Server: A standard Postgres instance serves as the global source of truth.
- The Sync Layer: The ElectricSQL sync service connects to Postgres via logical replication.
- The Client: The web application initializes a PGlite instance.
- The Connection: The Electric TypeScript client establishes a connection to the Sync Service and subscribes to specific data "Shapes."
- The Interaction: When the user performs an action, the app writes directly to PGlite. The UI updates instantly. ElectricSQL detects the local change and pushes it to the server. Simultaneously, it pulls changes from other users and applies them to the local PGlite instance.
Implementing the Stack
Let’s look at a simplified implementation of how you would set this up in a modern React application.
1. Initializing PGlite
First, we initialize our local database. We can choose to persist this to IndexedDB so that the data survives page refreshes.
import { PGlite } from '@electric-sql/pglite'; const db = new PGlite('idb://my-app-db');
2. Setting up ElectricSQL
Next, we wrap the PGlite instance with the Electric client. This requires the URL of your Electric Sync Service.
import { electrify } from 'electric-sql/pglite'; // Assuming you have a schema generated from your server DB import { schema } from './generated/client'; const config = { url: 'https://your-electric-service.com', }; const electric = await electrify(db, schema, config);
3. Syncing a Shape
To get data into the local PGlite instance, we define a shape. For a task management app, we might want to sync all tasks belonging to the current user.
const shape = await electric.db.tasks.sync({ where: { user_id: currentUserId, }, }); // Wait for the initial sync to complete await shape.synced;
4. Reactive UI Queries
Because we are using PGlite, we can query our data using standard SQL. Most integrations provide hooks to make this reactive. When the local database changes (either via user input or a sync update from another device), the component re-renders.
import { useLiveQuery } from '@electric-sql/pglite-react'; const TaskList = () => { const { results } = useLiveQuery( 'SELECT * FROM tasks WHERE status = $1 ORDER BY created_at DESC', ['todo'] ); return ( <ul> {results.map(task => ( <li key={task.id}>{task.title}</li> ))} </ul> ); };
Handling Conflicts and Consistency
One of the biggest hurdles in local-first is the "split-brain" scenario. Two users edit the same record while offline. Who wins?
ElectricSQL uses a mechanism based on LWW (Last-Write-Wins) at the column level, governed by causal ordering. Because it tracks the history of operations, it can determine which change happened "after" another in a causal sense.
For more complex business logic where LWW isn't enough, you can leverage Postgres's own relational integrity. If a local change violates a foreign key constraint on the server, the sync service can handle the rejection, and your application can listen for these "compensating actions" to update the local state or notify the user.
Performance and Scaling Considerations
While this architecture is powerful, it's important to be mindful of its constraints:
Bundle Size
WASM binaries are larger than standard JavaScript libraries. PGlite is highly optimized, but it still adds a few megabytes to your initial load. For many enterprise applications or complex SaaS tools, this is a negligible trade-off for the performance gains once the app is loaded. For a simple landing page, it's likely overkill.
Memory Usage
Running Postgres in a browser tab consumes memory. While PGlite is efficient, developers should be careful about the amount of data they sync locally. This is where ElectricSQL's "Shapes" API is critical—only sync what the user needs for the current session.
Migration Management
In a local-first world, migrations are more complex because you have thousands of "databases" out in the wild. ElectricSQL simplifies this by acting as the orchestrator. When you migrate your server-side Postgres database, Electric helps manage the propagation of those schema changes to the connected clients.
Real-World Use Case: Field Service Applications
Imagine an application for technicians repairing power lines in remote areas. They often have no cell service.
In a traditional architecture, the app would break the moment they lose signal. With PGlite and ElectricSQL, the technician can load their schedule and technical manuals while at the office (initial sync). In the field, they can record their work, upload photos (stored as paths in the DB), and update statuses. The app remains lightning-fast because every action is a local write. Once they return to an area with connectivity, ElectricSQL automatically streams those changes back to the central office, and pulls down any new emergency assignments that were added while they were offline.
Conclusion: The Path Forward
The shift toward local-first architecture represents a significant step forward in user experience. By moving the database to the edge, we eliminate the loading spinner and provide a level of responsiveness that traditional APIs simply cannot match.
PGlite and ElectricSQL provide a robust, Postgres-centric way to adopt this paradigm. They allow us to use the tools we already know—SQL, relational schemas, and reactive components—while solving the incredibly hard problem of data synchronization.
Actionable Next Steps:
- Evaluate your data needs: Identify if your application would benefit from offline capabilities or if latency is a major friction point.
- Prototype with PGlite: Try replacing a complex Redux store or a set of TanStack Query hooks with a local PGlite instance for a single feature.
- Explore ElectricSQL Shapes: Design your data synchronization strategy by defining which subsets of data are required locally for different user roles.
The era of the loading spinner is coming to an end. It's time to start building applications that feel as local as they actually are.