Local-First Web: Building Reactive Apps with PGlite and ElectricSQL
The Death of the Loading Spinner
For the last two decades, web development has been dominated by the request-response cycle. We build applications that are essentially thin clients, constantly reaching out to a remote server for every piece of state. This architecture has a fundamental flaw: it makes the user experience dependent on network reliability and latency. We’ve tried to mask this with optimistic UI updates and complex caching layers like TanStack Query or SWR, but these are often just sophisticated Band-Aids on a broken model.
Local-first architecture flips this script. Instead of the network being the primary data path, the local database becomes the source of truth. Your application reads from and writes to a local database, and a background process handles the heavy lifting of synchronizing that data with a central server.
In this article, we will explore a modern stack for achieving this: PGlite, a WASM-based Postgres build that runs in the browser, and ElectricSQL, the synchronization layer that bridges the gap between your local Postgres and your cloud infrastructure.
Why Postgres in the Browser?
Until recently, if you wanted a local database in the web browser, your options were limited. IndexedDB is the native standard, but its API is notoriously difficult to work with and lacks the relational power developers expect. SQLite (via WASM) has been the go-to alternative, offering a familiar SQL interface.
However, most backend systems today run on PostgreSQL. This creates a cognitive and technical mismatch: you write SQLite locally and Postgres on the server. You lose type parity, specific Postgres extensions, and the comfort of the same SQL dialect.
Enter PGlite
PGlite is a groundbreaking project that compiles the actual PostgreSQL source code into WebAssembly. It isn't a shim or an emulation; it is Postgres. It allows you to run a full relational database inside a browser tab, a Worker, or a Node.js process with zero external dependencies.
Key advantages of PGlite include:
- Single File/Memory Persistence: It can persist data to IndexedDB or run entirely in memory.
- Reactive Queries: It supports a live-query mechanism, allowing the UI to update automatically when the underlying data changes.
- Extensibility: Because it is real Postgres, it can theoretically support the vast ecosystem of Postgres extensions.
The Sync Challenge: Bidirectional Replication
Running a database locally is only half the battle. The real challenge is synchronization. How do you handle multiple users editing the same data while offline? How do you ensure that the local subset of data is consistent with the global state?
This is where ElectricSQL comes in. ElectricSQL provides a synchronization layer that turns Postgres into a reactive, real-time data source. It uses a "Shape"-based protocol to sync specific subsets of your database to the client.
How the Architecture Works
In a PGlite + ElectricSQL architecture, the flow looks like this:
- The Backend: You have a standard Postgres instance running in the cloud (AWS, Supabase, Neon, etc.).
- The Sync Service: ElectricSQL sits in front of your Postgres database, watching the logical replication stream.
- The Client: Your frontend application initializes PGlite.
- The Shape: The client subscribes to a "Shape"—a set of related tables and rows defined by a query.
- Synchronization: ElectricSQL streams the initial state and all subsequent changes to the PGlite instance. When the client makes a local change, it is captured and sent back to the server when the network is available.
Implementing the Stack
Let’s look at how we actually wire this up in a modern TypeScript application.
1. Initializing PGlite
First, we need to create our local database instance. PGlite makes this remarkably simple.
import { PGlite } from '@electric-sql/pglite'; // Initialize PGlite with IndexedDB persistence const db = new PGlite('idb://my-app-db'); // Create a table locally await db.exec(` CREATE TABLE IF NOT EXISTS todos ( id UUID PRIMARY KEY, task TEXT, completed BOOLEAN DEFAULT false, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); `);
2. Defining Shapes with ElectricSQL
ElectricSQL uses the concept of "Shapes" to manage what data is synced. You don't want to sync your entire 500GB production database to a user's smartphone. Instead, you define a subset.
Using the Electric SDK, you can sync data into your local PGlite instance:
import { syncShape } from '@electric-sql/client'; const shape = await syncShape({ url: 'https://api.electric-sql.cloud/v1/shape', table: 'todos', where: 'user_id = \'user_123\'', // Only sync this user's data subscribe: true }); // As data arrives from the server, it is automatically inserted into PGlite shape.subscribe((data) => { console.log("Synced data updated", data); });
3. Reactive UI Updates
One of the biggest benefits of this architecture is the elimination of complex state management. Instead of manual cache invalidation, you listen to the database.
// A simplified hook for a React component function useTodos() { const [todos, setTodos] = useState([]); useEffect(() => { // Live query in PGlite const unsubscribe = db.live.query( 'SELECT * FROM todos ORDER BY updated_at DESC', [], (results) => setTodos(results.rows) ); return () => unsubscribe(); }, []); return todos; }
When you call db.query("INSERT INTO todos..."), the live.query callback fires immediately. The UI updates at the speed of local memory, regardless of the network state. The sync service then propagates that change to the server in the background.
Handling Conflicts and Consistency
In any distributed system, conflicts are inevitable. If User A and User B both edit the same todo item while offline, what happens when they reconnect?
ElectricSQL handles this using Conflict-free Replicated Data Types (CRDTs) and a "Last Write Wins" (LWW) resolution policy by default at the column level. Because it operates on the database's logical replication stream, it can resolve conflicts with much higher granularity than a traditional API.
For example, if User A updates the task text and User B updates the completed status simultaneously, ElectricSQL can merge these changes perfectly. If they both update the task text, the version with the later timestamp wins, ensuring all nodes eventually converge on the same state.
Real-World Considerations
While this architecture is powerful, it requires a shift in how we think about security and data modeling.
Security and Row-Level Security (RLS)
In a local-first world, the client has a copy of the data. This means you must be rigorous about what data is synced. ElectricSQL integrates with Postgres's native Row-Level Security (RLS). When a client requests a Shape, the sync service validates the request against the RLS policies defined in your central Postgres instance. If a user isn't allowed to see a row, it never leaves the server.
Migration Management
Schema migrations are the bane of local-first development. If you change a table schema on the server, you need to ensure the local PGlite instances can handle it. ElectricSQL manages this by versioning shapes. When the server schema changes, the sync service can trigger a migration on the client or force a re-sync of the data to ensure consistency.
Storage Limits
Browsers impose limits on IndexedDB storage (often a percentage of available disk space). While PGlite is efficient, developers must be mindful of how much data they sync. Implementing "data expiration" or archiving strategies on the server is essential for long-lived applications.
The Strategic Advantage of Local-First
Adopting PGlite and ElectricSQL isn't just a technical choice; it's a product choice.
- Instantaneous UX: No more loading states for every click. The app feels like a native desktop application.
- Offline Capability: Your app works in subways, airplanes, and areas with spotty Wi-Fi. This is no longer a "feature" but a baseline expectation for modern software.
- Reduced Server Load: Since the client performs most reads and writes locally, your central database handles fewer concurrent connections and complex read queries.
- Simplified Frontend Code: You can delete thousands of lines of boilerplate code dedicated to caching, retries, and state synchronization.
Conclusion: Actionable Next Steps
Local-first architecture is moving from an experimental niche to a viable production standard. To start implementing this today:
- Audit your current state management: Identify where loading spinners and network latency are degrading your user experience.
- Prototype with PGlite: Replace a small, non-critical part of your application's state (like user preferences or a draft system) with a local PGlite instance.
- Evaluate ElectricSQL: Set up a local ElectricSQL sync service against a development Postgres instance and experiment with syncing a single table to your PGlite frontend.
- Shift your Mindset: Stop thinking about APIs and start thinking about data replication. The database is no longer just on the server; it’s everywhere your code runs.