Tekko

Language

Get in Touch

Usually respond within 24 hours

Back to BlogArchitecture

Local-First Web Architecture: Scaling PGlite and ElectricSQL

8 min read
PostgreSQLWASMLocal-FirstTypeScriptReact
Local-First Web Architecture: Scaling PGlite and ElectricSQL

For the last decade, web development has been dominated by the 'Cloud-First' paradigm. We treat the browser as a thin client, a window into a remote database. Every interaction—a click, a form submission, a toggle—is a round-trip to a server. While this simplified state management for a time, it introduced a persistent tax on user experience: latency, the 'loading spinner' fatigue, and total failure in offline scenarios.

Local-first software represents a fundamental shift in this architecture. Instead of the server being the primary source of truth for the UI, the client-side database takes that role. The UI responds instantly to local data, and synchronization happens asynchronously in the background.

Two technologies have recently emerged to make this high-performance pattern viable for Postgres-heavy stacks: PGlite and ElectricSQL. This article explores how to combine these tools to build reactive, offline-ready applications with bidirectional synchronization.

The Core Problem: The Latency Gap

In a traditional SPA (Single Page Application), the sequence is predictable: User triggers action -> API Request -> Database Update -> API Response -> UI Update. Even with a fast 100ms round-trip, the interface feels 'heavy.' On a flaky mobile connection, this becomes unusable.

Optimistic UI updates (manually updating the local state before the server responds) were a temporary fix, but they are notoriously difficult to implement correctly. You have to handle rollbacks, complex race conditions, and state reconciliation manually.

Local-first architectures solve this by moving the database to the client. When the database is local, queries take microseconds, not milliseconds. The challenge, however, has always been the 'Sync' part: how do you move data between a local client and a central Postgres server without losing your mind over conflict resolution?

PGlite: Postgres in the Browser

Until recently, 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, using it alongside a Postgres backend creates a 'dual-schema' problem. You have to translate types, handle different SQL dialects, and manage two different sets of migrations.

PGlite changes this. It is a WASM build of the actual Postgres source code, packaged into a clean TypeScript library. It allows you to run a fully functional Postgres instance inside your browser tab, a Node.js process, or a Bun environment.

Why PGlite is a Game Changer

  1. Full Postgres Syntax: You get access to JSONB, CTEs, window functions, and even certain extensions.
  2. Small Footprint: Despite being a full Postgres build, it's roughly 3MB compressed.
  3. Persistence Options: It can persist data to IndexedDB in the browser or the file system in Node.js.
  4. Single Dialect: Your frontend and backend speak the same language. You can share schema definitions and even some query logic.
import { PGlite } from '@electric-sql/pglite'; const db = new PGlite('idb://my-database'); await db.exec(` CREATE TABLE IF NOT EXISTS todos ( id UUID PRIMARY KEY, task TEXT, done BOOLEAN DEFAULT false ); `); await db.query('INSERT INTO todos (id, task) VALUES ($1, $2)', [ crypto.randomUUID(), 'Build a local-first app' ]);

ElectricSQL: The Synchronization Engine

Having a local database is only half the battle. You need a way to sync that data with your central authority. This is where ElectricSQL comes in.

ElectricSQL is a synchronization layer that sits between your central Postgres database and your local PGlite instances. It uses Postgres's native logical replication (the WAL - Write Ahead Log) to stream changes back and forth.

The "Shape" Concept

The brilliance of ElectricSQL lies in its 'Shapes.' In a traditional sync system, you often have to sync the entire database or manually manage complex subscriptions. With Electric, a 'Shape' is a subset of the database—defined by a table and its related associations—that a client subscribes to.

When a client subscribes to a Shape, Electric handles the initial fetch and then streams every subsequent change (INSERT, UPDATE, DELETE) to that client. If the client goes offline and comes back, Electric automatically catches them up from exactly where they left off.

Architectural Overview

To implement this, your stack looks like this:

  1. Central Postgres: Your cloud-hosted database (the source of truth).
  2. Electric Sync Service: An Elixir-based service that monitors the Postgres WAL and manages client connections via WebSockets.
  3. Client-side PGlite: The local database instance running in the user's browser.
  4. Electric TypeScript Client: The bridge that connects PGlite to the Sync Service.

Bidirectional Sync and Conflict Resolution

A common fear with bidirectional sync is conflict. What if two users edit the same row while offline?

ElectricSQL uses Causal Integrity and LWW (Last Write Wins) semantics by default, but it is built on the foundations of Rich-CRDTs (Conflict-free Replicated Data Types). It ensures that all nodes eventually converge to the same state without requiring a central coordinator to stop the world for locking.

Implementation: Building a Reactive Todo App

Let’s look at how we actually wire this up in a React context.

1. Define the Schema

First, you apply your migrations to your central Postgres database. ElectricSQL requires you to enable logical replication and 'electrify' the tables you want to sync.

-- On your central Postgres server CREATE TABLE items ( id UUID PRIMARY KEY, content TEXT NOT NULL, completed BOOLEAN NOT NULL DEFAULT false, last_modified_at TIMESTAMP NOT NULL ); ALTER TABLE items ENABLE ELECTRIC;

2. Initialize the Client

On the frontend, we initialize PGlite and hook it into the Electric client.

import { PGlite } from '@electric-sql/pglite'; import { electrify } from 'electric-sql/pglite'; // Initialize PGlite with IndexedDB persistence const conn = new PGlite('idb://example-db'); // Electrify the connection const electric = await electrify(conn, schema, { url: 'proxy-url-to-electric-service' });

3. Subscribing to Data

Instead of fetching data once, we subscribe to a 'Shape.' This ensures our local PGlite is kept in sync with the server.

const shape = await electric.db.items.sync(); // Once 'shape' is resolved, the local PGlite is populated.

4. Reactive Queries

Because we are using PGlite, we can use hooks to create 'Live Queries.' Whenever the local database changes (either via user input or via a sync update from the server), the UI re-renders automatically.

import { useLiveQuery } from '@electric-sql/pglite-react'; const TodoList = () => { const { results } = useLiveQuery( 'SELECT * FROM items ORDER BY last_modified_at DESC', [] ); return ( <ul> {results.map(item => ( <li key={item.id}>{item.content}</li> ))} </ul> ); };

Performance Considerations

While PGlite is incredibly efficient, running a database in a browser tab requires mindfulness regarding resource management.

IndexedDB Limits

Browsers impose limits on IndexedDB storage (often a percentage of available disk space). For most B2B or SaaS applications, this is plenty, but for media-heavy apps, you may need a strategy for clearing old data or limiting the scope of your Shapes.

Connection Management

ElectricSQL uses WebSockets. While highly efficient, you should manage the lifecycle of these connections. Electric handles reconnections gracefully, but you should monitor the sync status to provide visual feedback to the user (e.g., a 'Synced' vs. 'Offline' indicator).

Migration Strategy

In a local-first world, migrations are more complex. You cannot simply 'migrate the database' because there are thousands of databases (one on each client device). ElectricSQL handles this by versioning the sync protocol, ensuring that clients with older schema versions can still communicate or are prompted to refresh.

The Business Case for Local-First

Beyond the 'cool factor' of offline support, there are tangible business benefits to this architecture:

  1. Reduced Server Load: Most reads happen locally. Your central database no longer needs to handle every single 'SELECT' query for every user interaction.
  2. Superior UX: Zero-latency interfaces lead to higher user engagement and productivity. It makes a web app feel like a high-end native desktop application.
  3. Development Velocity: Once the sync layer is set up, developers stop writing API endpoints for every CRUD operation. You simply write to the local DB, and the data 'appears' on the server.

Conclusion: The Path Forward

The combination of PGlite and ElectricSQL represents a professional-grade approach to local-first development. By leveraging the power of Postgres on both the client and the server, we eliminate the friction of data translation and provide a robust, reactive foundation for modern web applications.

To get started, don't try to migrate your entire application at once. Identify a single high-interaction feature—like a document editor, a task manager, or a complex filter system—and implement it using a local-first pattern. Once you experience the elimination of loading states and the simplicity of reactive SQL queries in the frontend, the 'Cloud-First' round-trip will start to feel like a legacy constraint.

Actionable Next Steps:

  1. Spin up a local ElectricSQL stack using their Docker Compose starter.
  2. Initialize a PGlite instance in a simple Vite project.
  3. Define a 'Shape' for a single table and observe the bidirectional sync in the browser console.
  4. Measure the latency difference between a standard fetch and a PGlite local query.