Tekko

Language

Get in Touch

Usually respond within 24 hours

Back to BlogArchitecture

Scaling Local-First: Reactive Postgres with ElectricSQL and PGLite

8 min read
PostgreSQLLocal-FirstWebAssemblyElectricSQLArchitecture
Scaling Local-First: Reactive Postgres with ElectricSQL and PGLite

The traditional request-response model of web development is hitting a ceiling. As users demand increasingly fluid, collaborative, and offline-capable experiences, the latency inherent in round-trips to a central server has become a primary bottleneck. We have spent a decade trying to mask this latency with optimistic UI updates, complex loading states, and sophisticated caching layers like React Query or SWR.

However, these are often just patches on a fundamentally fragile architecture. The emerging "Local-First" movement proposes a different path: move the data to the client, let the user interact with a local database at 0ms latency, and handle synchronization as a background process.

In this article, we will explore the implementation of a local-first stack using two of the most promising tools in the ecosystem: ElectricSQL and PGLite. Together, they allow us to run a reactive version of Postgres directly in the browser and synchronize it with a cloud-based Postgres instance.

The Architecture of Local-First

In a standard web app, the database lives behind an API. In a local-first app, the database lives inside the client application (the browser, mobile app, or desktop environment).

This shift introduces three core requirements:

  1. A Robust Local Engine: A database capable of running in the browser that supports relational queries.
  2. Efficient Sync: A mechanism to replicate data between the client and the server without manual fetching.
  3. Conflict Resolution: A strategy for merging changes when multiple users edit the same data while offline.

ElectricSQL and PGLite address these by leveraging the world's most trusted database: PostgreSQL.

PGLite: Postgres in the Browser

Until recently, if you wanted a relational database in the browser, your best bet was SQLite compiled to WebAssembly (WASM). While excellent, it meant maintaining two different SQL dialects if your backend was Postgres.

PGLite changes the game. It is a full PostgreSQL build packaged as a WASM module that can run in the browser, Node.js, or Bun. It is remarkably small (~3MB gzipped) and provides a true Postgres experience.

Why PGLite Matters

PGLite isn't just a mock or a shim; it’s the actual Postgres engine. This means you get:

  • Full SQL Support: Common Table Expressions (CTEs), window functions, and complex joins.
  • JSONB Support: The same powerful document storage capabilities you use on the backend.
  • In-Memory or Persistent Storage: You can run it entirely in RAM or persist data to IndexedDB in the browser.
import { PGLite } from "@electric-sql/pglite"; const db = new PGLite(); await db.query("CREATE TABLE todos (id SERIAL PRIMARY KEY, task TEXT, done BOOLEAN);"); await db.query("INSERT INTO todos (task, done) VALUES ($1, $2);", ["Try PGLite", false]); const result = await db.query("SELECT * FROM todos;"); console.log(result.rows);

ElectricSQL: The Synchronization Bridge

Having a local database is only half the battle. You need a way to keep it in sync with your source of truth. ElectricSQL is a sync layer that sits between your backend Postgres and your local PGLite instance.

ElectricSQL uses Postgres's logical replication to capture changes in the cloud and stream them to the client. Conversely, it captures local changes and sends them back to the server.

The "Shapes" Concept

One of the most innovative features of the new ElectricSQL architecture (often referred to as Electric Next) is the concept of Shapes.

In a massive database, you cannot sync the entire dataset to every client. A "Shape" is a subset of your database—defined by a table and its related associations—that a client subscribes to.

For example, a user might subscribe to a shape representing only the "Projects" they own and the associated "Tasks". ElectricSQL handles the heavy lifting of identifying which rows belong in that shape and streaming updates to the client whenever those rows change on the server.

Building a Reactive Sync Implementation

Let’s look at how we integrate these components into a reactive web application. The goal is to have the UI update automatically when the local database changes, whether those changes came from a user action or a background sync from the server.

1. Setting up the Local Database

First, we initialize PGLite and wrap it in a provider that allows our UI components to subscribe to queries.

import { PGLite } from "@electric-sql/pglite"; import { live } from "@electric-sql/pglite/live"; // Initialize PGLite with the live plugin for reactivity const db = new PGLite({ plugins: [live()] });

2. Defining the Sync Shape

Using the ElectricSDK, we define what data we want to pull down. This is the bridge that connects our local PGLite instance to the remote Postgres.

import { ShapeStream } from "@electric-sql/client"; // Define a stream for the 'tasks' table const stream = new ShapeStream({ url: `${ELECTRIC_URL}/v1/shape/tasks`, }); // When data arrives from the server, upsert it into PGLite stream.subscribe((messages) => { for (const msg of messages) { if (msg.headers.control === 'up-to-date') continue; const { value } = msg; await db.query(` INSERT INTO tasks (id, title, completed) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title, completed = EXCLUDED.completed;`, [value.id, value.title, value.completed] ); } });

3. Reactive UI Components

Because we are using PGLite's live plugin, we can create hooks that re-render components whenever the underlying data changes. This eliminates the need for manual cache invalidation.

function TaskList() { // This hook automatically re-runs the query when the 'tasks' table changes const tasks = useLiveQuery("SELECT * FROM tasks ORDER BY id DESC"); return ( <ul> {tasks.map(task => ( <li key={task.id}>{task.title}</li> ))} </ul> ); }

Handling Conflicts and Consistency

In a local-first system, two users might edit the same record while offline. When they reconnect, whose changes win?

ElectricSQL uses Causal Integrity and is moving toward deeper integration with CRDTs (Conflict-free Replicated Data Types). By default, many local-first systems use a "Last Write Wins" (LWW) approach based on timestamps. However, because ElectricSQL sits on top of Postgres, you can leverage Postgres's own relational constraints to ensure data integrity even during complex merges.

One significant advantage of using ElectricSQL is that it handles the Active Replication protocol for you. It ensures that transactions are applied in order and that the client eventually reaches the same state as the server.

Performance and Practical Considerations

While local-first offers a superior user experience, it comes with its own set of engineering trade-offs.

Indexing in WASM

Just because the database is in the browser doesn't mean you can ignore performance. If you are querying thousands of rows in PGLite, you still need indexes. Fortunately, since PGLite is real Postgres, you can run CREATE INDEX just as you would on a server. This makes local searches and filtering incredibly fast compared to iterating over JavaScript arrays.

Initial Sync Latency

The first time a user opens your app, they need to download their "Shape" of data. This "initial hydrate" is the only time the user will experience traditional network latency. It is critical to optimize your shapes to be as small as possible and to provide clear UI feedback during this initial bootstrap phase.

Storage Limits

Browsers impose limits on IndexedDB storage (often a percentage of available disk space). While PGLite is efficient, you should be mindful of storing massive binary blobs or millions of rows locally. For most SaaS applications (tasks, CRM data, notes), this is rarely an issue.

Why This Beats Traditional State Management

For a senior engineer, the biggest draw of the ElectricSQL + PGLite stack is the simplification of the mental model.

In a traditional app, your state is fragmented:

  • Some state is in the database.
  • Some is in the API response cache.
  • Some is in a global state manager (Redux/Zustand).
  • Some is in local component state.

In a local-first app using PGLite, the database is the state.

If you want to update a UI element, you write a SQL UPDATE statement. The sync engine handles the networking, and the live query handles the UI update. You no longer have to worry about whether your local cache is out of sync with the server; the sync engine guarantees eventual consistency. This removes entire classes of bugs related to race conditions and network failures.

Conclusion

Moving to a local-first architecture with ElectricSQL and PGLite is a significant shift, but it’s one that addresses the fundamental frustrations of modern web development. By bringing the power of Postgres to the edge, we can build applications that are not only faster and more resilient but also significantly easier to reason about.

Actionable Next Steps:

  1. Audit your data needs: Identify which parts of your application would benefit most from 0ms latency (e.g., text editors, dashboards, or data-entry forms).
  2. Prototype with PGLite: Replace a complex piece of client-side state logic with a local PGLite instance to see how it simplifies your query logic.
  3. Explore ElectricSQL Shapes: Look at your backend schema and define the "Shapes" required for different user roles to understand how much data needs to be synced.

The era of the loading spinner is coming to an end. It's time to start building applications that work as fast as our users do.