Local-First Apps with ElectricSQL and PGLite: A New Standard
For the last two decades, web development has been dominated by the request-response model. We build APIs—REST, GraphQL, or RPC—to act as gatekeepers between our frontend and our database. This architecture, while battle-tested, introduces a fundamental friction: every user interaction is gated by network latency. Even with optimistic UI updates, managing state synchronization, cache invalidation, and offline persistence remains one of the most complex challenges in software engineering.
The "Local-First" movement aims to flip this script. Instead of the UI requesting data from a remote server, the data is already there, living in a local database within the client. Changes happen locally and sync in the background. Until recently, implementing this required niche technologies or complex CRDT libraries. However, the combination of ElectricSQL and PGLite has changed the game by bringing the power of Postgres directly into the browser and providing a robust sync layer between the client and the cloud.
The Architectural Shift: Moving the Data to the User
In a traditional SPA, the browser is a thin client. In a local-first application, the browser is a distributed node. To make this work, you need two things: a real database on the client and a way to keep that database in sync with your source of truth.
Why PGLite?
Historically, if you wanted a database in the browser, you 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 Postgres. You end up managing two different SQL dialects and two different sets of migrations.
PGLite is a breakthrough because it is a build of Postgres itself, compiled to WebAssembly and packaged as a lightweight TypeScript library. It allows you to run a full Postgres engine—complete with triggers, JSONB support, and extensions—inside the browser tab. It can persist data to IndexedDB or the Origin Private File System (OPFS), meaning your users' data survives a page refresh or a browser restart.
Why ElectricSQL?
Running a database locally is step one. Step two is synchronization. This is where ElectricSQL comes in. Electric provides a sync service that sits in front of your main Postgres database. It uses Change Data Capture (CDC) to stream updates from your server-side Postgres to your client-side PGLite instance and vice versa.
Unlike previous iterations of sync engines, the modern ElectricSQL architecture (often referred to as "Electric Next") focuses on "Shapes." A Shape is a subset of your database—specific tables and filtered rows—that you want to sync to a particular client. This allows for fine-grained control over what data lives on the device, preventing the client from being overwhelmed by a multi-terabyte production database.
Building the Sync Layer: The Core Concepts
To implement this stack, you need to understand the flow of data. It isn't just about "downloading data"; it's about subscribing to a live view of the database.
1. Defining Shapes
A Shape is defined by a root table and its related data. In a project management app, a Shape might be "all projects where I am a member, including their tasks and comments."
// Example of defining a shape in the client const shape = { url: `${ELECTRIC_URL}/v1/shape/projects`, params: { where: "team_id = 'engineering'", include: { tasks: true } } };
Electric handles the heavy lifting of tracking which LSN (Log Sequence Number) the client is on and streaming only the deltas. This is significantly more efficient than re-fetching data via a REST endpoint.
2. The PGLite Integration
Once the data arrives via the Electric sync service, it is written into PGLite. Because PGLite is a real Postgres instance, you can use standard SQL to query it. This is where the developer experience shines. You aren't learning a new proprietary query language; you're writing the same SQL you use on the backend.
import { PGLite } from '@electric-sql/pglite'; const db = new PGLite(); // Querying local data with standard SQL const result = await db.query("SELECT * FROM tasks WHERE status = 'todo' ORDER BY priority DESC");
Implementing Reactivity
The true power of local-first is felt when the UI reacts instantly to data changes, whether those changes come from the user's own actions or from a sync event triggered by another user.
With PGLite and Electric, you can implement a "live query" mechanism. Instead of fetching data once, you subscribe to a query. When the local PGLite database is updated (via a sync or a local write), the query re-runs, and the UI updates automatically.
function TaskList() { const tasks = useLiveQuery("SELECT * FROM tasks WHERE completed = false"); const toggleTask = async (id) => { // This update is local and instant await db.query("UPDATE tasks SET completed = true WHERE id = $1", [id]); // ElectricSQL will handle syncing this back to the server in the background }; return ( <ul> {tasks.map(task => ( <li key={task.id} onClick={() => toggleTask(task.id)}> {task.title} </li> ))} </ul> ); }
Solving the Connectivity Problem
In a traditional app, if the user loses connection, the app breaks. You might add a serviceWorker for assets, but the data remains inaccessible. With ElectricSQL and PGLite, the app is functional by default.
- Offline Writes: When a user performs an action while offline, the change is committed to the local PGLite instance. The UI updates immediately.
- Queueing: ElectricSQL tracks these local changes in a write-ahead log or a pending transactions table.
- Reconnection: Once the network is restored, Electric pushes the pending changes to the server.
- Conflict Resolution: Electric uses a causal consistency model. By default, it follows a "Last Write Wins" (LWW) approach based on physical clocks or logical timestamps, but it is designed to ensure that all nodes eventually converge to the same state.
Technical Deep Dive: Why This Beats the Alternatives
REST/GraphQL + React Query
In the standard stack, React Query (or SWR) manages a cache. However, this cache is often a glorified JSON object. It doesn't understand relational logic. If you update a "Task," you have to manually invalidate the "Project" query that contains it. With PGLite, the database handles the relational integrity. An update to a row is reflected across all queries joining that row because they are querying a real relational engine, not a key-value store.
Firebase/Firestore
Firebase was the pioneer of real-time sync, but it locks you into a proprietary NoSQL document store. Transitioning away from Firebase is notoriously difficult. ElectricSQL uses Postgres—the industry standard. Your data remains in a standard relational format, and your server-side logic remains decoupled from your sync provider.
Replicache
Replicache is an excellent tool for local-first, but it requires you to implement a specific backend protocol and maintain a key-value store. ElectricSQL provides a more "plug-and-play" experience for teams already using Postgres, as it leverages CDC to minimize the amount of custom backend code required.
Performance and Scaling Considerations
While syncing an entire database to the browser sounds heavy, the "Shapes" approach mitigates this. However, as a senior engineer, you must consider the following:
- Initial Sync Latency: The first time a user opens the app, they must download the initial Shape. For large datasets, this can be several megabytes. It's crucial to design your Shapes to be as small as possible and use pagination or lazy-loading for historical data.
- WASM Overhead: PGLite adds about 3-5MB to your bundle size (gzipped). For a lightweight landing page, this is too much. For a complex SaaS tool (like Linear, Notion, or an ERP), this is a rounding error compared to the UX benefits.
- IndexedDB Limits: While modern browsers allow significant storage, you must still monitor disk usage. PGLite is efficient, but storing millions of rows in a browser is rarely the right move. Use Electric's filtering to keep the local footprint lean.
Practical Example: Collaborative Inventory Management
Imagine a warehouse app where workers scan items in areas with spotty Wi-Fi.
- The Setup: You define a Shape for the specific warehouse the worker is assigned to.
- The Operation: The worker scans items, updating the
inventorytable in PGLite. The UI increments the count instantly. - The Sync: The worker walks into a walk-in freezer (no signal). They continue scanning. When they step out, ElectricSQL detects the network and streams the 50 new scans to the central Postgres DB.
- The Conflict: Another worker updated the same item's count from a desktop terminal. ElectricSQL merges these changes based on the timestamp, ensuring the final count reflects both updates.
The Path Forward
Local-first isn't just a trend; it's the natural evolution of web applications. Users expect software to be fast, reliable, and functional regardless of their internet connection.
By combining Postgres (the world's most trusted database), PGLite (Postgres in WASM), and ElectricSQL (the sync engine), we finally have a stack that makes local-first development accessible to mainstream engineering teams. You no longer need a PhD in distributed systems to build a collaborative, offline-ready application.
Actionable Conclusion
To get started with this stack:
- Audit your current data flow: Identify which parts of your application suffer most from network latency.
- Spin up an Electric service: Use their Docker image to connect to an existing Postgres instance.
- Integrate PGLite: Replace a few high-frequency Fetch/REST calls with a PGLite live query.
- Measure the impact: Observe the reduction in "loading" states and the improvement in user satisfaction scores.
The era of the loading spinner is coming to an end. It's time to start building applications that feel as fast as local software because, for the first time, they actually are.