Skip to content

Web adapter

Terminal window
npm install @livestore/adapter-web @livestore/wa-sqlite
import {
const makePersistedAdapter: (options: WebAdapterOptions) => Adapter

Creates a web adapter with persistent storage (currently only supports OPFS). Requires both a web worker and a shared worker.

On browsers without SharedWorker support (e.g. Android Chrome), this adapter automatically falls back to single-tab mode. In single-tab mode:

  • Each tab runs independently with its own leader worker
  • Multi-tab synchronization is not available
  • Devtools are not supported

@seehttps://github.com/livestorejs/livestore/issues/321 - SharedWorker tracking issue

@seehttps://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug

@example

import { makePersistedAdapter } from '@livestore/adapter-web'
import LiveStoreWorker from './livestore.worker.ts?worker'
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({
worker: LiveStoreWorker,
sharedWorker: LiveStoreSharedWorker,
storage: { type: 'opfs' },
})

makePersistedAdapter
} from '@livestore/adapter-web'
import
const LiveStoreWorker: new (options?: {
name?: string;
}) => Worker
LiveStoreWorker
from './livestore.worker.ts?worker'
const
const adapter: Adapter
adapter
=
function makePersistedAdapter(options: WebAdapterOptions): Adapter

Creates a web adapter with persistent storage (currently only supports OPFS). Requires both a web worker and a shared worker.

On browsers without SharedWorker support (e.g. Android Chrome), this adapter automatically falls back to single-tab mode. In single-tab mode:

  • Each tab runs independently with its own leader worker
  • Multi-tab synchronization is not available
  • Devtools are not supported

@seehttps://github.com/livestorejs/livestore/issues/321 - SharedWorker tracking issue

@seehttps://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug

@example

import { makePersistedAdapter } from '@livestore/adapter-web'
import LiveStoreWorker from './livestore.worker.ts?worker'
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({
worker: LiveStoreWorker,
sharedWorker: LiveStoreSharedWorker,
storage: { type: 'opfs' },
})

makePersistedAdapter
({
storage: {
readonly type: "opfs";
readonly directory?: string | undefined;
}

Specifies where to persist data for this adapter

storage
: {
type: "opfs"
type
: 'opfs' },
worker: ((options: {
name: string;
}) => globalThis.Worker) | (new (options: {
name: string;
}) => globalThis.Worker)
worker
:
const LiveStoreWorker: new (options?: {
name?: string;
}) => Worker
LiveStoreWorker
,
sharedWorker: ((options: {
name: string;
}) => globalThis.SharedWorker) | (new (options: {
name: string;
}) => globalThis.SharedWorker)

This is mostly an implementation detail and needed to be exposed into app code due to a current Vite limitation (https://github.com/vitejs/vite/issues/8427).

In most cases this should look like:

import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({
sharedWorker: LiveStoreSharedWorker,
// ...
})

sharedWorker
:
const LiveStoreSharedWorker: new (options?: {
name?: string;
}) => SharedWorker
LiveStoreSharedWorker
,
})
import {
const makeWorker: (options: WorkerOptions) => void
makeWorker
} from '@livestore/adapter-web/worker'
import {
import schema
schema
} from './schema/index.ts'
function makeWorker(options: WorkerOptions): void
makeWorker
({
schema: LiveStoreSchema<DbSchema, EventDefRecord>
schema
})
import {
const makeWorker: (options: WorkerOptions) => void
makeWorker
} from '@livestore/adapter-web/worker'
import {
const makeWsSync: (options: WsSyncOptions) => SyncBackendConstructor<SyncMetadata>

Creates a sync backend that uses WebSocket to communicate with the sync backend.

@example

import { makeWsSync } from '@livestore/sync-cf/client'
const syncBackend = makeWsSync({ url: 'wss://sync.example.com' })

makeWsSync
} from '@livestore/sync-cf/client'
import {
import schema
schema
} from './schema/index.ts'
function makeWorker(options: WorkerOptions): void
makeWorker
({
schema: LiveStoreSchema<DbSchema, EventDefRecord>
schema
,
sync?: SyncOptions
sync
: {
backend?: SyncBackendConstructor<any, Json>
backend
:
function makeWsSync(options: WsSyncOptions): SyncBackendConstructor<SyncMetadata>

Creates a sync backend that uses WebSocket to communicate with the sync backend.

@example

import { makeWsSync } from '@livestore/sync-cf/client'
const syncBackend = makeWsSync({ url: 'wss://sync.example.com' })

makeWsSync
({
WsSyncOptions.url: string

URL of the sync backend

The protocol can either http/https or ws/wss

url
: 'ws://localhost:8787' }) } })

You can also use the in-memory adapter which can be useful in certain scenarios (e.g. testing).

import {
const makeInMemoryAdapter: (options?: InMemoryAdapterOptions) => Adapter

Creates a web-only in-memory LiveStore adapter.

This adapter runs entirely in memory with no persistence. Ideal for:

  • Unit tests and integration tests
  • Sandboxes and demos
  • Ephemeral sessions where persistence isn't needed

Characteristics:

  • Fast, zero I/O overhead
  • Works in all browser contexts: Window, WebWorker, SharedWorker, ServiceWorker
  • Supports optional sync backends for real-time collaboration
  • No data persists after page reload

For persistent storage, use makePersistedAdapter instead.

@example

import { makeInMemoryAdapter } from '@livestore/adapter-web'
const adapter = makeInMemoryAdapter()

@example

// With sync backend for real-time collaboration
import { makeInMemoryAdapter } from '@livestore/adapter-web'
import { makeWsSync } from '@livestore/sync-cf/client'
const adapter = makeInMemoryAdapter({
sync: {
backend: makeWsSync({ url: 'wss://api.example.com/sync' }),
},
})

@example

// Pre-populate with existing data
const adapter = makeInMemoryAdapter({
importSnapshot: existingDbSnapshot,
})

makeInMemoryAdapter
} from '@livestore/adapter-web'
const
const adapter: Adapter
adapter
=
function makeInMemoryAdapter(options?: InMemoryAdapterOptions): Adapter

Creates a web-only in-memory LiveStore adapter.

This adapter runs entirely in memory with no persistence. Ideal for:

  • Unit tests and integration tests
  • Sandboxes and demos
  • Ephemeral sessions where persistence isn't needed

Characteristics:

  • Fast, zero I/O overhead
  • Works in all browser contexts: Window, WebWorker, SharedWorker, ServiceWorker
  • Supports optional sync backends for real-time collaboration
  • No data persists after page reload

For persistent storage, use makePersistedAdapter instead.

@example

import { makeInMemoryAdapter } from '@livestore/adapter-web'
const adapter = makeInMemoryAdapter()

@example

// With sync backend for real-time collaboration
import { makeInMemoryAdapter } from '@livestore/adapter-web'
import { makeWsSync } from '@livestore/sync-cf/client'
const adapter = makeInMemoryAdapter({
sync: {
backend: makeWsSync({ url: 'wss://api.example.com/sync' }),
},
})

@example

// Pre-populate with existing data
const adapter = makeInMemoryAdapter({
importSnapshot: existingDbSnapshot,
})

makeInMemoryAdapter
()
  • Make sure your schema doesn’t depend on any code which needs to run in the main thread (e.g. avoid importing from files using React)
    • Unfortunately this constraints you from co-locating your table definitions in component files.
    • You might be able to still work around this by using the following import in your worker:
      import '@livestore/adapter-web/worker-vite-dev-polyfill'

Why is there a dedicated web worker and a shared worker?

Section titled “Why is there a dedicated web worker and a shared worker?”
  • Shared worker:
    • Needed to allow tabs to communicate with each other using a binary message channel.
    • The shared worker mostly acts as a proxy to the dedicated web worker.
  • Dedicated web worker (also called “leader worker” via leader election mechanism using web locks):
    • Acts as the leader/single writer for the storage.
    • Also handles connection to sync backend.
    • Currently needed for synchronous OPFS API which isn’t supported in a shared worker. (Hopefully won’t be needed in the future anymore.)
  • While service workers seem similar to shared workers (i.e. only a single instance across all tabs), they serve different purposes and have different trade-offs.
  • Service workers are meant to be used to intercept network requests and tend to “shut down” when there are no requests for some period of time making them unsuitable for our use case.
  • Also note that service workers don’t support some needed APIs such as OPFS.

LiveStore currently only support OPFS to locally persist its data. In the future we might add support for other storage types (e.g. IndexedDB).

During development (NODE_ENV !== 'production'), LiveStore automatically copies older state database files into archive/ inside the OPFS directory for the store (e.g. livestore-<storeId>@<version>/archive/). The three most recent copies are retained so you can inspect pre-migration data; older archives are pruned. In production, we delete outdated state databases immediately.

LiveStore also uses window.sessionStorage to retain the identity of a client session (e.g. tab/window) across reloads.

In Safari and Firefox private browsing mode, OPFS is not available due to browser restrictions. When this happens, LiveStore automatically falls back to in-memory storage. This means:

  • The app will continue to work normally during the session
  • Data will not persist across page reloads or tab closures
  • Sync functionality (if configured) will still work

You can detect when the store is running in-memory mode using store.storageMode:

if (store.storageMode === 'in-memory') {
// Show a warning to the user
showToast('Data will not be saved in private browsing mode')
}

The storageMode property returns:

  • 'persisted': Data is being persisted to disk (e.g., via OPFS)
  • 'in-memory': Data is only stored in memory and will be lost on page refresh

You can also listen for boot status events including warnings using the onBootStatus callback in your store options:

const useAppStore = () => useStore({
storeId: 'app',
schema,
adapter,
batchUpdates: ReactDOM.unstable_batchedUpdates,
onBootStatus: (status) => {
if (status.stage === 'warning') {
console.warn(`Storage warning (${status.reason}): ${status.message}`)
}
},
})

Resetting local persistence only clears data stored in the browser and does not affect any connected sync backend.

In case you want to reset the local persistence of a client, you can provide the resetPersistence option to the adapter.

import {
const makePersistedAdapter: (options: WebAdapterOptions) => Adapter

Creates a web adapter with persistent storage (currently only supports OPFS). Requires both a web worker and a shared worker.

On browsers without SharedWorker support (e.g. Android Chrome), this adapter automatically falls back to single-tab mode. In single-tab mode:

  • Each tab runs independently with its own leader worker
  • Multi-tab synchronization is not available
  • Devtools are not supported

@seehttps://github.com/livestorejs/livestore/issues/321 - SharedWorker tracking issue

@seehttps://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug

@example

import { makePersistedAdapter } from '@livestore/adapter-web'
import LiveStoreWorker from './livestore.worker.ts?worker'
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({
worker: LiveStoreWorker,
sharedWorker: LiveStoreSharedWorker,
storage: { type: 'opfs' },
})

makePersistedAdapter
} from '@livestore/adapter-web'
import
const LiveStoreWorker: new (options?: {
name?: string;
}) => Worker
LiveStoreWorker
from './livestore.worker.ts?worker'
const
const resetPersistence: boolean
resetPersistence
= import.

The type of import.meta.

If you need to declare that a given property exists on import.meta, this type may be augmented via interface merging.

meta
.
ImportMeta.env: Bun.Env & NodeJS.ProcessEnv & ImportMetaEnv

The environment variables of the process

import.meta.env === process.env

env
.
ImportMetaEnv.DEV: boolean
DEV
&& new
var URLSearchParams: new (init?: string[][] | Record<string, string> | string | URLSearchParams) => URLSearchParams

The URLSearchParams interface defines utility methods to work with the query string of a URL.

MDN Reference

URLSearchParams
(
var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

MDN Reference

window
.
location: Location

The read-only location property of the Window interface returns a Location object with information about the current location of the document.

MDN Reference

The read-only location property of the WorkerGlobalScope interface returns the WorkerLocation associated with the worker. It is a specific location object, mostly a subset of the Location for browsing scopes, but adapted to workers.

MDN Reference

location
.
Location.search: string

The search property of the Location interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the location's URL. If the URL does not have a search query, this property contains an empty string, "".

MDN Reference

search
).
URLSearchParams.get(name: string): string | null (+1 overload)

The get() method of the URLSearchParams interface returns the first value associated to the given search parameter.

MDN Reference

get
('reset') !== null
if (
const resetPersistence: boolean
resetPersistence
=== true) {
const
const searchParams: URLSearchParams
searchParams
= new
var URLSearchParams: new (init?: string[][] | Record<string, string> | string | URLSearchParams) => URLSearchParams

The URLSearchParams interface defines utility methods to work with the query string of a URL.

MDN Reference

URLSearchParams
(
var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

MDN Reference

window
.
location: Location

The read-only location property of the Window interface returns a Location object with information about the current location of the document.

MDN Reference

The read-only location property of the WorkerGlobalScope interface returns the WorkerLocation associated with the worker. It is a specific location object, mostly a subset of the Location for browsing scopes, but adapted to workers.

MDN Reference

location
.
Location.search: string

The search property of the Location interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the location's URL. If the URL does not have a search query, this property contains an empty string, "".

MDN Reference

search
)
const searchParams: URLSearchParams
searchParams
.
URLSearchParams.delete(name: string, value?: string): void (+1 overload)

The delete() method of the URLSearchParams interface deletes specified parameters and their associated value(s) from the list of all search parameters.

MDN Reference

delete
('reset')
var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

MDN Reference

window
.
history: History

The Window.history read-only property returns a reference to the History object, which provides an interface for manipulating the browser session history (pages visited in the tab or frame that the current page is loaded in).

MDN Reference

history
.
History.replaceState(data: any, unused: string, url?: string | URL | null): void

The replaceState() method of the History interface modifies the current history entry, replacing it with the state object and URL passed in the method parameters. This method is particularly useful when you want to update the state object or URL of the current history entry in response to some user action.

MDN Reference

replaceState
(null, '', `${
var window: Window & typeof globalThis

The window property of a Window object points to the window object itself.

MDN Reference

window
.
location: Location

The read-only location property of the Window interface returns a Location object with information about the current location of the document.

MDN Reference

The read-only location property of the WorkerGlobalScope interface returns the WorkerLocation associated with the worker. It is a specific location object, mostly a subset of the Location for browsing scopes, but adapted to workers.

MDN Reference

location
.
Location.pathname: string

The pathname property of the Location interface is a string containing the path of the URL for the location. If there is no path, pathname will be empty: otherwise, pathname contains an initial '/' followed by the path of the URL, not including the query string or fragment.

MDN Reference

pathname
}?${
const searchParams: URLSearchParams
searchParams
.
URLSearchParams.toString(): string (+1 overload)
toString
()}`)
}
const
const adapter: Adapter
adapter
=
function makePersistedAdapter(options: WebAdapterOptions): Adapter

Creates a web adapter with persistent storage (currently only supports OPFS). Requires both a web worker and a shared worker.

On browsers without SharedWorker support (e.g. Android Chrome), this adapter automatically falls back to single-tab mode. In single-tab mode:

  • Each tab runs independently with its own leader worker
  • Multi-tab synchronization is not available
  • Devtools are not supported

@seehttps://github.com/livestorejs/livestore/issues/321 - SharedWorker tracking issue

@seehttps://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug

@example

import { makePersistedAdapter } from '@livestore/adapter-web'
import LiveStoreWorker from './livestore.worker.ts?worker'
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({
worker: LiveStoreWorker,
sharedWorker: LiveStoreSharedWorker,
storage: { type: 'opfs' },
})

makePersistedAdapter
({
storage: {
readonly type: "opfs";
readonly directory?: string | undefined;
}

Specifies where to persist data for this adapter

storage
: {
type: "opfs"
type
: 'opfs' },
worker: ((options: {
name: string;
}) => globalThis.Worker) | (new (options: {
name: string;
}) => globalThis.Worker)
worker
:
const LiveStoreWorker: new (options?: {
name?: string;
}) => Worker
LiveStoreWorker
,
sharedWorker: ((options: {
name: string;
}) => globalThis.SharedWorker) | (new (options: {
name: string;
}) => globalThis.SharedWorker)

This is mostly an implementation detail and needed to be exposed into app code due to a current Vite limitation (https://github.com/vitejs/vite/issues/8427).

In most cases this should look like:

import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'
const adapter = makePersistedAdapter({
sharedWorker: LiveStoreSharedWorker,
// ...
})

sharedWorker
:
const LiveStoreSharedWorker: new (options?: {
name?: string;
}) => SharedWorker
LiveStoreSharedWorker
,
resetPersistence?: boolean

Warning: This will reset both the app and eventlog database. This should only be used during development.

@defaultfalse

resetPersistence
,
})

If you want to reset persistence manually, you can:

  1. Clear site data in Chrome DevTools (Application tab > Storage > Clear site data)
  2. Use console command if the above doesn’t work due to a Chrome OPFS bug:
const opfsRoot = await navigator.storage.getDirectory();
await opfsRoot.remove();

Note: Only use this during development while the app is running.

Assuming the web adapter in a multi-client, multi-tab browser application, a diagram looks like this:

  • The web adapter is using some browser APIs that might require a HTTPS connection (e.g. navigator.locks). It’s recommended to even use HTTPS during local development (e.g. via Caddy).
  • Notable required browser APIs: OPFS, navigator.locks, WASM
  • SharedWorker is used for multi-tab synchronization but is not strictly required

Android Chrome does not support the SharedWorker API (Chromium bug #40290702). When running on Android Chrome, LiveStore automatically falls back to single-tab mode:

  • Each browser tab runs independently with its own leader worker
  • Data is still persisted to OPFS (same as full mode)
  • Multi-tab synchronization is not available
  • Devtools are not supported in single-tab mode
  • A warning is logged to the console when this fallback occurs

You can also explicitly use single-tab mode if you don’t need multi-tab support:

import {
const makeSingleTabAdapter: (options: SingleTabAdapterOptions) => Adapter

Creates a single-tab web adapter with OPFS persistence.

This adapter is a fallback for browsers without SharedWorker support (notably Android Chrome). It provides the same persistence capabilities as makePersistedAdapter, but without multi-tab synchronization. Each browser tab runs its own independent leader worker.

In most cases, you should use makePersistedAdapter instead, which automatically falls back to this adapter when SharedWorker is unavailable.

Limitations:

  • No multi-tab synchronization (each tab operates independently)
  • No devtools support (requires SharedWorker)
  • Opening multiple tabs with the same storeId may cause data conflicts

@seehttps://github.com/livestorejs/livestore/issues/321 - LiveStore tracking issue

@seehttps://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug

@example

import { makeSingleTabAdapter } from '@livestore/adapter-web'
import LiveStoreWorker from './livestore.worker.ts?worker'
// Only use this directly if you specifically need single-tab mode.
// Prefer makePersistedAdapter which auto-detects SharedWorker support.
const adapter = makeSingleTabAdapter({
worker: LiveStoreWorker,
storage: { type: 'opfs' },
})

makeSingleTabAdapter
} from '@livestore/adapter-web'
import
const LiveStoreWorker: new (options?: {
name?: string;
}) => Worker
LiveStoreWorker
from './livestore.worker.ts?worker'
// Use this only if you specifically need single-tab mode.
// Prefer makePersistedAdapter which auto-detects SharedWorker support.
const
const adapter: Adapter
adapter
=
function makeSingleTabAdapter(options: SingleTabAdapterOptions): Adapter

Creates a single-tab web adapter with OPFS persistence.

This adapter is a fallback for browsers without SharedWorker support (notably Android Chrome). It provides the same persistence capabilities as makePersistedAdapter, but without multi-tab synchronization. Each browser tab runs its own independent leader worker.

In most cases, you should use makePersistedAdapter instead, which automatically falls back to this adapter when SharedWorker is unavailable.

Limitations:

  • No multi-tab synchronization (each tab operates independently)
  • No devtools support (requires SharedWorker)
  • Opening multiple tabs with the same storeId may cause data conflicts

@seehttps://github.com/livestorejs/livestore/issues/321 - LiveStore tracking issue

@seehttps://issues.chromium.org/issues/40290702 - Chromium SharedWorker bug

@example

import { makeSingleTabAdapter } from '@livestore/adapter-web'
import LiveStoreWorker from './livestore.worker.ts?worker'
// Only use this directly if you specifically need single-tab mode.
// Prefer makePersistedAdapter which auto-detects SharedWorker support.
const adapter = makeSingleTabAdapter({
worker: LiveStoreWorker,
storage: { type: 'opfs' },
})

makeSingleTabAdapter
({
worker: ((options: {
name: string;
}) => globalThis.Worker) | (new (options: {
name: string;
}) => globalThis.Worker)

The dedicated web worker that runs the LiveStore leader thread.

@example

import LiveStoreWorker from './livestore.worker.ts?worker'
const adapter = makeSingleTabAdapter({
worker: LiveStoreWorker,
storage: { type: 'opfs' },
})

worker
:
const LiveStoreWorker: new (options?: {
name?: string;
}) => Worker
LiveStoreWorker
,
storage: {
readonly type: "opfs";
readonly directory?: string | undefined;
}

Storage configuration. Currently only OPFS is supported.

storage
: {
type: "opfs"
type
: 'opfs' },
})
  • It’s recommended to develop in an incognito window to avoid issues with persistent storage (e.g. OPFS).

What’s the bundle size of the web adapter?

Section titled “What’s the bundle size of the web adapter?”

LiveStore with the web adapter adds two parts to your application bundle:

  • The LiveStore JavaScript bundle (~180KB gzipped)
  • SQLite WASM (~300KB gzipped)