Skip to content
On this page

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:

bash
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins pg

In the frontend:

bash
npm install @jcbuisson/express-x @jcbuisson/express-x-plugins socket.io-client @electric-sql/client @electric-sql/pglite rxjs vue

The 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.

sql
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:

yaml
# 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:
bash
docker compose up -d --wait

For deployment, pin a tested Electric image version and keep Electric behind the ExpressX Shape proxy. Source credentials belong on the server.

Server setup ​

js
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 optionDefaultDescription
syncfalseEnable versioned mutations and tombstones
electricUrlELECTRIC_URL or http://localhost:3000/v1/shapeUpstream Electric endpoint
shapePath/electric/v1/shape/:modelExpressX Shape proxy route
sourceId, sourceSecretnoneCredentials attached to upstream requests
authorizeAllow allPolicy for reads and mutations
fetchglobalThis.fetchCustom 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:

js
{
   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 ​

js
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 optionDefaultDescription
syncfalseEnable managed persistent offline models
databaseNamenoneRequired IndexedDB database name in managed mode
shapePath/electric/v1/shapeBase URL for the ExpressX Shape proxy
onErrornoneCallback receiving the error and { modelName }
initTimeoutMs30000Worker initialization timeout in milliseconds
workerFactoryPlugin workerCustom function returning a Worker
localDbPlugin-owned PGliteWorkerSupply 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.

js
// 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 optionDefaultDescription
primaryKey'uid'Primary key column; must match the server and table
urlshapePath + '/' + modelNameOverride this model's Shape endpoint; use this instead of streamOptions.url
streamOptions{} plus managed visibility behaviorOptions 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'
onErrorPlugin-level onErrorOffline model error callback receiving (error, { modelName }) in managed mode; direct streams report errors through their subscriptions
retryMs5000Offline mutation retry interval in milliseconds
networkglobalThis.navigatorOffline model network state provider exposing onLine; normally leave this unchanged
localDbManaged plugin database, otherwise noneSupply a PGlite database for an explicitly managed offline model; with sync: true, use plugin-level localDb instead
ownsSynctrue for explicit offline modelsWhether this model runs its stream and mutation queue; managed sync controls ownership automatically
channelManaged BroadcastChannel, otherwise noneBroadcastChannel 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:

js
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:

js
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 ​

MethodBehavior 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:

js
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:

js
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:

js
import '@jcbuisson/express-x-plugins/electric-worker'

Then pass this option to electricClientPlugin:

js
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:

js
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.