Tekko

Language

Get in Touch →

Usually respond within 24 hours

Back to BlogArchitecture

Local-First Sync with ElectricSQL and PGLite: The New Standard

8 min read
PostgresLocal-FirstElectricSQLPGLiteTypeScript
Local-First Sync with ElectricSQL and PGLite: The New Standard

Building web applications has traditionally followed a rigid request-response pattern. We fetch data from an API, store it in a local state management library like Redux or TanStack Query, and then struggle to keep that state in sync with a remote database. When the network drops or latency spikes, the user experience degrades into a sea of loading spinners and optimistic UI hacks that are notoriously difficult to maintain.

Local-first software represents a paradigm shift. Instead of treating the server as the primary source of truth for the UI, we treat a local database as the primary source. The server becomes a synchronization and persistence layer. With the emergence of ElectricSQL and PGLite, we finally have a robust, Postgres-native way to implement this architecture at scale.

The Problem with the Modern API Stack

Most developers spend 30-50% of their time writing 'plumbing' code: defining REST or GraphQL endpoints, handling serialization, managing cache invalidation, and implementing complex retry logic for flaky connections. This overhead exists because there is a fundamental disconnect between the relational data on the server and the state objects on the client.

When we move to a local-first model, we eliminate this middle layer. The application queries a local database with zero latency. Changes are written locally first and then asynchronously synced to the backend. This provides an 'instant' feel to the UI, regardless of network conditions.

The Core Components: PGLite and ElectricSQL

To build a Postgres-to-browser replication pipeline, we need two specific pieces of infrastructure: a high-performance database in the browser and a sync engine capable of moving data between the cloud and the client.

PGLite: Postgres in the Browser

PGLite is a WASM build of Postgres packaged into a client-side library. Unlike previous attempts at browser-based SQL (like WebSQL or various SQLite wrappers), PGLite is actual Postgres. It supports many Postgres features, including triggers, recursive CTEs, and extensions, all running inside a browser tab or a Web Worker.

Because PGLite is lightweight (around 3MB gzipped), it can be initialized in milliseconds. It provides an in-memory or persistent (via IndexedDB) storage engine, allowing your frontend code to execute real SQL queries against local data.

ElectricSQL: The Sync Engine

ElectricSQL is the glue. It is a sync service that sits between your main Postgres database and your local PGLite instances. It uses logical replication to track changes in the primary database and streams those changes to clients using a protocol optimized for the web.

One of ElectricSQL's most powerful features is the concept of Shapes. A Shape is a subset of your database—specific tables and filtered rows—that a client subscribes to. Instead of syncing the entire multi-terabyte production database, a user only syncs the data they are authorized to see and interact with.

Architecting the Sync Flow

The architecture follows a clear path:

  1. Primary Database: Your standard Postgres instance (RDS, Supabase, Neon, etc.).
  2. Electric Service: A proxy that consumes the Postgres Write-Ahead Log (WAL) and exposes a 'Shape' API.
  3. Client Library: The Electric SDK running in the browser, which manages the connection to the Electric Service.
  4. Local Store: PGLite, which receives the replicated data and serves as the data source for the UI.

Implementing a Reactive Data Layer

Let's look at how we initialize this stack in a TypeScript environment. First, we set up PGLite and integrate it with the Electric client.

import { PGLite } from '@electric-sql/pglite'; import { createShapeStream } from '@electric-sql/client'; // Initialize PGLite with IndexedDB persistence const pg = new PGLite('idb://my-app-db'); // Define a 'Shape' to sync - e.g., all projects for the current user const shapeStream = createShapeStream({ url: 'https://api.electric-sql.cloud/v1/shape/projects', params: { where: "user_id = 'user_123'" } }); // Process the stream and update PGLite shapeStream.subscribe(messages => { for (const msg of messages) { const { headers, value } = msg; // Perform UPSERT operations into PGLite based on incoming WAL events if (headers.operation === 'insert' || headers.operation === 'update') { pg.query( "INSERT INTO projects (id, name) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name", [value.id, value.name] ); } } });

Why This Beats Traditional Caching

In a traditional React app using TanStack Query, you are essentially building a key-value store for JSON blobs. If you update a project's name in one view, you have to manually invalidate the cache for every other query that might contain that project to ensure UI consistency.

With PGLite, you have a relational local store. If you update a row in the projects table, any SQL query or reactive hook observing that table will immediately reflect the change. You get cross-component data consistency for free because all components are querying the same local source of truth.

Handling Reactive UI Updates

Modern local-first libraries provide hooks to make SQL queries reactive. Imagine a simple React component:

function ProjectList() { // This hook rerenders the component whenever the underlying PGLite table changes const { rows: projects } = useLiveQuery("SELECT * FROM projects ORDER BY created_at DESC"); return ( <ul> {projects.map(p => ( <li key={p.id}>{p.name}</li> ))} </ul> ); }

When the Electric sync engine receives a change from the server (perhaps another user updated a project), it pushes that change to PGLite. PGLite updates the local table, and the useLiveQuery hook triggers a re-render. The user sees the update in real-time without a single manual fetch or WebSocket message handler.

The Challenge of Offline Writes and Conflict Resolution

Local-first isn't just about reading data; it's about writing it while offline. When a user performs an action in a disconnected state, the change is committed to PGLite immediately. When the connection is restored, ElectricSQL handles the replication back to the server.

Causal Consistency

ElectricSQL uses a causal consistency model. It ensures that the order of operations is preserved. If User A creates a task and then User B comments on it, Electric ensures the task creation is processed before the comment across all nodes.

Conflict Handling

What happens if two users edit the same row while offline? ElectricSQL defaults to Last Write Wins (LWW) based on a hybrid logical clock. For most SaaS applications (Jira, Linear, Notion), this is often sufficient. For more complex scenarios, developers can implement CRDTs (Conflict-free Replicated Data Types) or use application-specific merging logic in the Postgres backend via triggers.

Security and Authorization

A common concern with replicating data to the browser is security. You cannot simply sync the entire database to every client.

ElectricSQL solves this through its Shapes API and integration with Postgres's existing security model. You define which rows a user can access using standard SQL logic. When the client requests a Shape, the Electric service validates the user's session (typically via JWT) and filters the replication stream accordingly. This ensures that the local PGLite instance only ever contains data the user is explicitly authorized to see.

Real-World Performance Implications

Using PGLite and ElectricSQL significantly changes the performance profile of an application:

  1. Initial Load: There is a one-time cost to download the WASM binary and the initial data Shape. However, subsequent loads are nearly instantaneous as the data is read from IndexedDB.
  2. Interaction Latency: Every click, toggle, and text input that triggers a database write happens in <10ms, as it only needs to hit the local WASM database.
  3. Server Load: The server no longer handles thousands of 'GET' requests for the same data. It primarily handles long-lived replication streams, which is much more efficient for Postgres than constant connection overhead from REST APIs.

Practical Steps for Implementation

If you are looking to transition an existing application or start a new one with this stack, follow these steps:

  1. Schema Design: Keep your Postgres schema clean. ElectricSQL works best with standard relational patterns and primary keys.
  2. Deployment: Run the Electric service as a sidecar to your Postgres database. It needs access to the logical replication slot.
  3. Client-Side Integration: Start by migrating one high-interaction feature (like a task list or a chat interface) to a Shape. You don't have to move the entire app to local-first all at once.
  4. Worker-Based Execution: Run PGLite inside a Web Worker. This keeps the database execution off the main UI thread, ensuring that complex SQL queries don't cause frame drops in your animations.

Conclusion

The combination of ElectricSQL and PGLite effectively bridges the gap between the robustness of Postgres and the responsiveness required by modern web users. By treating the browser as a legitimate database node rather than a temporary cache, we eliminate the complexities of state management and network error handling that have plagued web development for a decade.

To get started, evaluate your application's most 'active' data. Moving that data into a local-first sync loop is the most effective way to improve user experience and reduce the maintenance burden of your API layer. The era of the loading spinner is coming to an end; the era of the local-first, reactive database has arrived.