Offline plugin
The ElectricSQL plugin provides local-first PostgreSQL models: reads use a persistent browser cache, writes work offline, and changes synchronize when the connection returns. ExpressX handles server mutations and authorization, Electric streams committed rows, and PGlite stores local rows and pending edits in IndexedDB.
This guide covers @jcbuisson/express-x-plugins 5.0.7, using electricServerPlugin and electricClientPlugin with sync: true on both sides.
Installation
In the backend:
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins pgIn the frontend:
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins socket.io-client @electric-sql/client @electric-sql/pglite rxjs vueThe server requires Node.js 18 or newer. Managed browser sync requires HTTPS or localhost, IndexedDB, workers, Web Locks, and BroadcastChannel.
PostgreSQL and Electric
Create a table with a UUID primary key. Sync mode requires client-generated UUIDs; serial or identity keys are supported only in direct mode.
CREATE TABLE todos (
uid UUID PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT FALSE
);Electric needs PostgreSQL logical replication. Configure wal_level = logical, sufficient replication slots and WAL senders, and a database role with replication privileges. Restart PostgreSQL after changing these server settings.
Run an Electric service connected to that database. For local development on Docker Desktop:
# docker-compose.yml
services:
electric:
image: electricsql/electric:latest
environment:
DATABASE_URL: postgresql://user:password@host.docker.internal:5432/todoDB
ELECTRIC_INSECURE: "true"
ELECTRIC_STORAGE: FAST_FILE
ELECTRIC_PERSISTENT_STATE: FILE
ELECTRIC_STORAGE_DIR: /var/lib/electric
ports:
- "3001:3000"
volumes:
- electric_data:/var/lib/electric
volumes:
electric_data:docker compose up -d --waitFor deployment, pin a tested Electric image version and keep Electric behind the ExpressX Shape proxy. Source credentials belong on the server.
Server setup
import { Pool } from 'pg'
import { expressX } from '@jcbuisson/express-x/server'
import {
electricServerPlugin,
prepareElectricSyncSchema,
} from '@jcbuisson/express-x-plugins/electric-server'
const app = expressX()
const db = new Pool({ connectionString: process.env.DATABASE_URL })
const models = [{ name: 'todos', tombstoneData: { title: '' } }]
// Adds version/deleted columns, a version sequence, and a mutation cursor table.
await prepareElectricSyncSchema(db, models)
// Configure authentication before registering the plugin.
app.configure(electricServerPlugin, db, models, {
sync: true,
electricUrl: 'http://localhost:3001/v1/shape',
authorize: async (context, operation) => {
const user = context.request?.user ?? context.socket?.data?.user
// Example policy for a shared todo list: all authenticated users have access.
return Boolean(user) && operation.modelName === 'todos'
},
})
app.httpServer.listen(8000)Models can be table-name strings or objects with name, table, primaryKey, and tombstoneData. The default table is name and the default primary key is uid. For example, { name: 'todo', table: 'todos', primaryKey: 'id' } exposes the todo service for the todos table; set the same primaryKey on its client model.
authorize(context, operation) runs for Shape requests and service calls. The operation contains modelName, action, and args; actions are shape, findUnique, findMany, create, update, and delete. Shape requests have context.transport === 'http'; service calls use the WebSocket context. Authenticate both transports. The package defaults to allowing requests when authorize is omitted, so supply your application's access policy. Client filters alone do not enforce row-level authorization.
| Server option | Default | Description |
|---|---|---|
sync | false | Enable versioned mutations and tombstones |
electricUrl | ELECTRIC_URL or http://localhost:3000/v1/shape | Upstream Electric endpoint |
shapePath | /electric/v1/shape/:model | ExpressX Shape proxy route |
sourceId, sourceSecret | none | Credentials attached to upstream requests |
authorize | Allow all | Policy for reads and mutations |
fetch | globalThis.fetch | Custom Fetch implementation |
Tombstones and retries
Deletes retain a row with deleted: true and a new version. Provide tombstoneData for required columns without defaults. It can be an object or an async function receiving { id }; use a function when unique columns need a value derived from the UUID:
{
name: 'users',
tombstoneData: ({ id }) => ({ email: `deleted-${id}@tombstone.invalid` }),
}Reserve those values for tombstones. Returned keys cannot include the primary key, version, or deleted. The function also runs when an update targets a missing or deleted row.
The server records each client's row revision and mutation result in the same transaction as the write. Retried or older revisions return the saved result, preventing delayed requests from overwriting a newer edit from that client. This mechanism does not resolve concurrent edits from different clients.
Keep tombstones and the mutation cursor table so offline clients and delayed retries remain valid. All writers to synchronized tables must advance version; hard deletes bypass delete confirmation.
Client setup
import { io } from 'socket.io-client'
import { createClient } from '@jcbuisson/express-x/client'
import { electricClientPlugin } from '@jcbuisson/express-x-plugins/electric-client'
const socket = io('http://localhost:8000', { transports: ['websocket'] })
const app = createClient(socket)
app.configure(electricClientPlugin, {
sync: true,
databaseName: 'todo-app',
shapePath: '/electric/v1/shape',
onError: (error, { modelName }) => console.error(modelName, error),
})
const todos = app.createElectricModel('todos')
const subscription = todos.getObservable({ completed: false }).subscribe({
next: rows => console.log('Incomplete todos', rows),
error: error => console.error('Model initialization failed', error),
})
const todo = await todos.create({ title: 'Buy milk', completed: false })
await todos.update(todo.uid, { completed: true })
console.log(await todos.findUnique({ uid: todo.uid }))
await todos.remove(todo.uid)
console.log(await todos.getStatus())
// At application teardown, including HMR disposal:
subscription.unsubscribe()
await app.disposeElectricSync()Models prepare and start automatically. Calls wait for local schema readiness, and repeated calls for the same model reuse its instance. Choose a persistent databaseName unique to your application/backend. The default primary key is uid; configure { primaryKey: 'id' } on models backed by an id column, and use the same setting on the server.
The Shape URL is relative to the frontend origin. If the frontend runs on a separate development server, proxy /electric/v1/shape to ExpressX, or set an absolute shapePath with the appropriate authentication and CORS configuration.
| Client option | Default | Description |
|---|---|---|
sync | false | Enable managed persistent offline models |
databaseName | none | Required IndexedDB database name in managed mode |
shapePath | /electric/v1/shape | Base URL for the ExpressX Shape proxy |
onError | none | Callback receiving the error and { modelName } |
initTimeoutMs | 30000 | Worker initialization timeout in milliseconds |
workerFactory | Plugin worker | Custom function returning a Worker |
localDb | Plugin-owned PGliteWorker | Supply an existing shared PGlite database |
createElectricModel(modelName, options = {})
modelName selects the ExpressX service and the model segment of the Shape URL. It must be a simple SQL identifier, as must primaryKey. Plugin options above configure the application; the options below configure an individual model.
// Defaults: uid primary key and client-generated UUIDs.
const users = app.createElectricModel('users')
// A table whose primary key is named id (configure the server to match).
const tasks = app.createElectricModel('tasks', { primaryKey: 'id' })| Model option | Default | Description |
|---|---|---|
primaryKey | 'uid' | Primary key column; must match the server and table |
url | shapePath + '/' + modelName | Override this model's Shape endpoint; use this instead of streamOptions.url |
streamOptions | {} plus managed visibility behavior | Options passed to Electric's ShapeStream, such as headers, params, and runtimeVisibility |
idGeneration | 'client' | Direct mode: generate a UUID for create(data). Use 'server' for a database-generated key. Managed sync requires client-generated UUIDs and rejects 'server' |
onError | Plugin-level onError | Offline model error callback receiving (error, { modelName }) in managed mode; direct streams report errors through their subscriptions |
retryMs | 5000 | Offline mutation retry interval in milliseconds |
network | globalThis.navigator | Offline model network state provider exposing onLine; normally leave this unchanged |
localDb | Managed plugin database, otherwise none | Supply a PGlite database for an explicitly managed offline model; with sync: true, use plugin-level localDb instead |
ownsSync | true for explicit offline models | Whether this model runs its stream and mutation queue; managed sync controls ownership automatically |
channel | Managed BroadcastChannel, otherwise none | BroadcastChannel for explicit offline models sharing a database; managed sync creates its own |
retryMs, network, onError, ownsSync, and channel apply to offline models. In direct mode without localDb, they do not configure the stream. idGeneration controls direct models without localDb; explicit offline models always generate UUIDs locally.
In managed mode, the first call for a model creates and caches it. Later calls return the same model without applying new options; changing its primaryKey throws. Configure all options on the first call.
Stream options and tab visibility
streamOptions configures the server-to-client Electric stream, independently of the mutation queue. For example, attach authentication headers or change a model's endpoint:
const users = app.createElectricModel('users', {
url: new URL('/electric/v1/shape/users', window.location.origin).href,
streamOptions: {
headers: { Authorization: () => `Bearer ${getAccessToken()}` },
},
})With sync: true, one tab owns synchronization for all tabs using the model. The plugin therefore keeps Electric streaming even when that tab is hidden. This is automatic since 5.0.7; applications do not need a visibility override. Independent direct streams retain Electric's normal visibility behavior. Browser suspension can still interrupt background execution.
For a custom runtime lifecycle, supply streamOptions.runtimeVisibility:
runtimeVisibility: {
getCurrentState: () => 'visible',
subscribe: onStateChange => {
// Connect your runtime's visibility events to onStateChange('visible' | 'hidden').
return () => { /* Remove those listeners. */ }
},
}getCurrentState reports the current state; subscribe registers changes and returns a cleanup function. A supplied adapter replaces the managed default. Reporting 'hidden' pauses the owner's stream and therefore affects all tabs that depend on it.
In direct mode, filters from getObservable(where) are converted to Electric params and override matching entries in streamOptions.params. In offline mode, read filters select cached rows; they do not change the shared stream. If you restrict that stream with streamOptions.params, the cache contains only that subset. Changing options on a cached model does not create a new subset.
Model API
| Method | Behavior in sync mode |
|---|---|
create(data) | Generate a UUID, persist the row and queue its creation; return the local row |
update(id, data) | Persist and queue an edit; return the local row |
remove(id) | Hide the row locally and queue its deletion |
findMany(where = {}) | Read matching rows from the local cache |
findUnique(where) | Return the first matching local row, or null |
getObservable(where = {}) | Emit local rows whenever they change |
getStatus() | Return pending, failed, online, and error |
flushQueue() | Attempt queued writes on the tab owning sync |
stop() | Stop synchronization and release ownership, retaining rows and queued edits |
start() | Resume synchronization |
dispose() | Stop the model and close its channel |
Reads can be empty before the first Electric snapshot arrives. An offline cache contains only data already synchronized. When an online flow needs an authoritative server read, use app.service('todos').findMany({ deleted: false }); server reads include tombstones unless filtered out.
Filters support exact values, null, and gt, gte, lt, and lte ranges:
const rows = await todos.findMany({
completed: false,
title: 'Buy milk',
})Offline behavior and multiple tabs
Local edits and queue entries commit together. A successful local write means the edit is persisted in the browser; server acceptance and synchronization happen later. After a service response, the entry remains pending until Electric delivers the acknowledged version or a newer one. Deleted rows confirm queued deletions but remain hidden locally.
Network failures remain queued for retry; permanent HTTP 4xx failures are marked failed. Surface getStatus() and onError in the application so users can see edits requiring attention. Initialization failures reject reads and writes and error observable subscriptions. Keep the local database, client identity, and revision sequence persistent across reloads.
The plugin shares one persistent PGliteWorker per instance. A Web Lock per database/model elects one tab to run the Electric stream and mutation queue; follower tabs notify it through BroadcastChannel. Another tab takes over when the owner stops or closes. Cached BIGINT values use decimal strings to preserve precision.
await app.disposeElectricSync() disposes all managed models and closes the plugin-owned database. If you supply localDb, your application owns its lifetime.
Vite and offline reloads
Preserve worker asset URLs and emit workers as ES modules:
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: {
exclude: ['@jcbuisson/express-x-plugins', '@electric-sql/pglite'],
},
worker: { format: 'es' },
server: {
proxy: {
'/electric/v1/shape': 'http://localhost:8000',
},
},
})For bundlers needing an application-owned worker entry, create electric.worker.js:
import '@jcbuisson/express-x-plugins/electric-worker'Then pass this option to electricClientPlugin:
workerFactory: () => new Worker(new URL('./electric.worker.js', import.meta.url), {
type: 'module',
})Persistent data alone does not make the application reloadable offline. A PWA must also cache the application shell, worker files, and emitted PGlite .wasm and .data assets. The WASM asset is about 10 MB, so increase your precache file-size limit accordingly.
Direct mode and explicit database management
Without sync: true, models read through Electric Shapes and send writes directly to ExpressX; they do not queue offline mutations. findMany() waits for the first Shape emission. Use { primaryKey: 'id', idGeneration: 'server' } for a database-generated id key in this mode, and configure the server primary key to match. Client mutation methods are still create, update, and remove; the server service exposes delete.
For applications managing their own synchronization lifecycle, omit plugin-level sync: true and pass localDb on each model:
const todos = app.createElectricModel('todos', {
localDb: db, // Application-owned persistent PGlite database
ownsSync: true,
channel: new BroadcastChannel('todos-sync'),
})
await todos.prepare()
todos.start()This still requires server sync mode. Only one owner per shared database/model should have ownsSync: true; other tabs need the same channel name and ownsSync: false. Your application must arrange ownership handover, stop models, and close its channels and database. Managed sync handles that coordination automatically.