Tekko

Language

Get in Touch →

Usually respond within 24 hours

Back to BlogWeb Development

Local-First React: Building Offline Apps with ElectricSQL and PGlite

8 min read
ReactPostgreSQLElectricSQLLocal-FirstWebAssembly
Local-First React: Building Offline Apps with ElectricSQL and PGlite

For the better part of two decades, web development has been dominated by the Request-Response cycle. We build an interface, the user performs an action, we send a JSON payload to a REST or GraphQL API, wait for the server to update a database, and then reflect that change back to the user. This model, while reliable, introduces inherent latency and a fragile dependency on constant connectivity.

As users, we have grown accustomed to the 'spinner'—that ubiquitous symbol of our data being in transit. But as engineers, we are starting to realize there is a better way. The 'Local-First' movement aims to eliminate the spinner by treating the local device as the primary data source, with the cloud serving as a secondary synchronization and backup layer.

Two technologies have recently emerged as frontrunners in making this vision a reality for React developers: PGlite and ElectricSQL. Together, they allow us to run a full Postgres instance in the browser and keep it in sync with a server-side Postgres database with minimal boilerplate.

The Shift to Local-First Architecture

In a traditional 'Cloud-First' application, the browser is a thin client. In a 'Local-First' application, the browser is a thick client that manages its own persistence. This shift offers three primary advantages:

  1. Zero Latency: Reads and writes happen against a local database. The UI updates at the speed of the local CPU and disk (or memory), regardless of network conditions.
  2. Offline Capability: The application remains fully functional without an internet connection. Changes are queued locally and synced once connectivity is restored.
  3. Simplified State Management: When the database is local and reactive, you no longer need complex state management libraries like Redux or Thunk to manage 'optimistic updates.' The database is the state.

PGlite: Postgres in a WebAssembly Sandbox

Until recently, running a relational database in the browser meant using SQLite via Wasm or IndexedDB. While SQLite is excellent, it creates a 'dialect mismatch' if your backend is running Postgres. You end up writing different migration scripts, different queries, and handling different data types.

PGlite changes this. It is a build of Postgres 15+ packaged as a Wasm library. It is tiny (approx. 3MB gzipped), starts up in milliseconds, and provides a full Postgres environment including support for extensions like pgvector.

Because PGlite runs in the browser's main thread or a Web Worker, it allows you to execute standard SQL queries directly against a local store. However, a local database is only useful if it can talk to the rest of the world. That is where ElectricSQL comes in.

ElectricSQL: The Sync Engine

ElectricSQL is a synchronization layer that sits between your primary Postgres database and your local PGlite instances. It uses a concept called 'Shapes' to manage data flow.

A 'Shape' is a subset of your database—specific tables and columns, often filtered by a set of criteria (like user_id)—that you want to sync to the client. ElectricSQL monitors the Postgres logical replication stream on the server and pushes changes to the client-side PGlite instance in real-time. Conversely, it handles the ingestion of local changes back into the master database.

Setting Up the Local-First Stack

To build a local-first React app, we need to initialize PGlite and configure the ElectricSQL client to manage the sync process.

1. Initializing PGlite

First, we install the necessary packages:

npm install @electric-sql/pglite @electric-sql/client

Then, we can initialize our local database. PGlite can persist data to IndexedDB, ensuring that the data survives page refreshes.

import { PGlite } from '@electric-sql/pglite'; // Initialize PGlite with IndexedDB persistence const db = new PGlite('idb://my-app-db');

2. Defining a Sync Shape

With ElectricSQL, we don't sync the entire multi-terabyte production database. We define a 'Shape'. For a todo application, the shape might look like this:

import { Shape } from '@electric-sql/client'; const todoShape = new Shape({ table: 'todos', where: `project_id = 'project-123'` });

3. Connecting the Sync Stream

The ElectricSQL client coordinates the movement of data between the server and PGlite. When the client starts, it fetches the initial data for the defined shapes and then listens for incremental updates.

import { ElectricClient } from '@electric-sql/client'; const electric = await ElectricClient.setup({ db, // Our PGlite instance url: 'https://your-electric-service.com', }); // Start syncing the todos shape const sync = await electric.sync(todoShape);

Reactivity: The useLiveQuery Hook

One of the most powerful features of this stack is reactivity. In a cloud-first app, if another user updates a record, you often have to wait for a WebSocket message or poll the API to update the UI. In a local-first app with ElectricSQL, the sync engine updates the local PGlite instance, and the UI reacts automatically.

ElectricSQL provides a useLiveQuery hook that allows your components to subscribe to SQL queries.

import { useLiveQuery } from '@electric-sql/pglite-react'; function TodoList() { const { data: todos, status } = useLiveQuery( 'SELECT * FROM todos ORDER BY created_at DESC', [] ); if (status === 'loading') return <p>Loading...</p>; return ( <ul> {todos.map(todo => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); }

When a change arrives from the server via ElectricSQL, it is written to PGlite. PGlite then triggers a re-render of any component observing that data. The developer doesn't have to write a single line of cache-invalidation logic.

Handling Data Conflicts

In any distributed system where writes can happen offline, conflicts are inevitable. User A and User B might both edit the same todo item while offline.

ElectricSQL handles this using a Causally Consistent approach. By default, it uses a "Last Write Wins" (LWW) resolution at the column level, based on hybrid logical clocks. This ensures that all nodes eventually converge on the same state without requiring manual conflict resolution in the majority of use cases. For more complex scenarios, you can design your schema using CRDT-friendly patterns (Conflict-free Replicated Data Types), though ElectricSQL aims to hide this complexity from the average developer.

Practical Considerations for Production

While the developer experience is stellar, moving a local-first app to production requires thinking about several architectural constraints.

Storage Limits

Browsers impose limits on IndexedDB storage (often a percentage of available disk space). While PGlite is efficient, you must be mindful of how much data you sync. This is why 'Shapes' are critical; you should only sync the data the current user actually needs to perform their tasks.

Schema Migrations

Migrations in a local-first world are twofold. You have your server-side Postgres migrations, and you have the local PGlite schema. ElectricSQL simplifies this by automatically evolving the local schema to match the 'Shape' definition provided by the server. However, you still need a strategy for handling breaking changes that might affect clients that have been offline for an extended period.

Authentication and Security

Security in local-first is different. Since the user has a full database in their browser, you cannot rely on "hiding" data via API endpoints. Security must be enforced at the sync layer. ElectricSQL integrates with standard Auth providers (like Supabase Auth or Auth0) and uses JWTs to ensure that a user can only subscribe to 'Shapes' they are authorized to see. Row-Level Security (RLS) on the server-side Postgres instance remains your primary line of defense.

Why This Matters for Technical Decision Makers

Choosing a local-first architecture is not just about 'cool tech.' It is a business decision. Apps built this way are inherently more robust. They work on subways, in airplanes, and in rural areas with spotty 5G.

Furthermore, it reduces the load on your primary application servers. Since the browser handles most of the query processing, your backend effectively becomes a specialized file server for database logs. This can lead to significant cost savings at scale and a much simpler backend architecture that doesn't need to handle complex caching strategies (like Redis) just to keep the UI snappy.

Actionable Conclusion

Implementing local-first synchronization with ElectricSQL and PGlite represents a fundamental shift in how we build web applications. It moves the complexity of data synchronization from the application code to the infrastructure layer, allowing developers to focus on building features rather than managing state.

To get started:

  1. Audit your current 'spinner' usage: Identify parts of your application where high latency or offline failures frustrate users.
  2. Spin up a PGlite instance: Try replacing a small piece of local state (like a draft form or a list of notifications) with PGlite.
  3. Explore ElectricSQL Shapes: Define a sync boundary for a specific feature and see how much 'glue code' you can delete by letting the sync engine handle the data flow.

The future of the web is not just connected—it is resilient, reactive, and local-first.