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.4, 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 (
id 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 id. For example, { name: 'todo', table: 'todos', primaryKey: 'uid' } 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.id, { completed: true })
console.log(await todos.findUnique({ id: todo.id }))
await todos.remove(todo.id)
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 id; configure { primaryKey: 'uid' } on models using another column.
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 |
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 { idGeneration: 'server' } for database-generated keys in this mode. 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.