# Web adapter

## Installation

```bash
npm install @livestore/adapter-web @livestore/wa-sqlite
```

## Example


## `reference/platform-adapters/web-adapter/main.ts`

```ts filename="reference/platform-adapters/web-adapter/main.ts"
/** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet keeps inline adapter */
// ---cut---
import { makePersistedAdapter } from '@livestore/adapter-web'
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'

import LiveStoreWorker from './livestore.worker.ts?worker'

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker: LiveStoreWorker,
  sharedWorker: LiveStoreSharedWorker,
})
```


## `reference/platform-adapters/web-adapter/livestore.worker.ts`

```ts filename="reference/platform-adapters/web-adapter/livestore.worker.ts"
import { makeWorker } from '@livestore/adapter-web/worker'

import { schema } from './schema/index.ts'

makeWorker({ schema })
```

### `reference/platform-adapters/web-adapter/schema/index.ts`

```ts filename="reference/platform-adapters/web-adapter/schema/index.ts"
import { defineMaterializer, Events, makeSchema, Schema, State } from '@livestore/livestore'

const tables = {
  todos: State.SQLite.table({
    name: 'todos',
    columns: {
      id: State.SQLite.text({ primaryKey: true }),
      text: State.SQLite.text(),
      completed: State.SQLite.boolean({ default: false }),
    },
  }),
} as const

const events = {
  todoCreated: Events.synced({
    name: 'v1.TodoCreated',
    schema: Schema.Struct({ id: Schema.String, text: Schema.String }),
  }),
} as const

const materializers = State.SQLite.materializers(events, {
  [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }) =>
    tables.todos.insert({ id, text, completed: false }),
  ),
})

const state = State.SQLite.makeState({ tables, materializers })

export const schema = makeSchema({ events, state })
```

## Adding a sync backend


## `reference/platform-adapters/web-adapter/sync-backend.ts`

```ts filename="reference/platform-adapters/web-adapter/sync-backend.ts"
import { makeWorker } from '@livestore/adapter-web/worker'
import { makeWsSync } from '@livestore/sync-cf/client'

import { schema } from './schema/index.ts'

makeWorker({ schema, sync: { backend: makeWsSync({ url: 'ws://localhost:8787' }) } })
```

### `reference/platform-adapters/web-adapter/schema/index.ts`

```ts filename="reference/platform-adapters/web-adapter/schema/index.ts"
import { defineMaterializer, Events, makeSchema, Schema, State } from '@livestore/livestore'

const tables = {
  todos: State.SQLite.table({
    name: 'todos',
    columns: {
      id: State.SQLite.text({ primaryKey: true }),
      text: State.SQLite.text(),
      completed: State.SQLite.boolean({ default: false }),
    },
  }),
} as const

const events = {
  todoCreated: Events.synced({
    name: 'v1.TodoCreated',
    schema: Schema.Struct({ id: Schema.String, text: Schema.String }),
  }),
} as const

const materializers = State.SQLite.materializers(events, {
  [events.todoCreated.name]: defineMaterializer(events.todoCreated, ({ id, text }) =>
    tables.todos.insert({ id, text, completed: false }),
  ),
})

const state = State.SQLite.makeState({ tables, materializers })

export const schema = makeSchema({ events, state })
```

## In-memory adapter

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


## `reference/platform-adapters/web-adapter/in-memory.ts`

```ts filename="reference/platform-adapters/web-adapter/in-memory.ts"
/** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet keeps inline adapter */
// ---cut---
import { makeInMemoryAdapter } from '@livestore/adapter-web'

const adapter = makeInMemoryAdapter()
```

## Web worker

- 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:
    ```ts
    import '@livestore/adapter-web/worker-vite-dev-polyfill'
    ```

### 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.)

### Why not use a service worker?

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

## Storage

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.

### Private browsing mode

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

```tsx
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:

```ts
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

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.


## `reference/platform-adapters/web-adapter/reset-persistence.ts`

```ts filename="reference/platform-adapters/web-adapter/reset-persistence.ts"
/** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet keeps inline adapter */
// ---cut---
import { makePersistedAdapter } from '@livestore/adapter-web'
import LiveStoreSharedWorker from '@livestore/adapter-web/shared-worker?sharedworker'

import LiveStoreWorker from './livestore.worker.ts?worker'

const resetPersistence = import.meta.env.DEV && new URLSearchParams(window.location.search).get('reset') !== null

if (resetPersistence === true) {
  const searchParams = new URLSearchParams(window.location.search)
  searchParams.delete('reset')
  window.history.replaceState(null, '', `${window.location.pathname}?${searchParams.toString()}`)
}

const adapter = makePersistedAdapter({
  storage: { type: 'opfs' },
  worker: LiveStoreWorker,
  sharedWorker: LiveStoreSharedWorker,
  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:

```javascript
const opfsRoot = await navigator.storage.getDirectory();
await opfsRoot.remove();
```

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

## Architecture diagram

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

![](https://i.imgur.com/NCKbfub.png)

## Other notes

- 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](https://caddyserver.com/docs/automatic-https)).

## Browser support

- Notable required browser APIs: OPFS, `navigator.locks`, WASM
- SharedWorker is used for multi-tab synchronization but is not strictly required

### Android Chrome (Single-tab mode)

Android Chrome does not support the `SharedWorker` API ([Chromium bug #40290702](https://issues.chromium.org/issues/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:


## `reference/platform-adapters/web-adapter/single-tab.ts`

```ts filename="reference/platform-adapters/web-adapter/single-tab.ts"
/** biome-ignore-all lint/correctness/noUnusedVariables: docs snippet keeps inline adapter */
// ---cut---
import { makeSingleTabAdapter } from '@livestore/adapter-web'

import LiveStoreWorker from './livestore.worker.ts?worker'

// Use this only if you specifically need single-tab mode.
// Prefer makePersistedAdapter which auto-detects SharedWorker support.
const adapter = makeSingleTabAdapter({
  worker: LiveStoreWorker,
  storage: { type: 'opfs' },
})
```

:::note
The single-tab adapter is intended as a fallback for browsers without SharedWorker support.
We plan to remove it once SharedWorker is supported in Android Chrome.
Track progress: [LiveStore #321](https://github.com/livestorejs/livestore/issues/321)
:::

## Best practices

- It's recommended to develop in an incognito window to avoid issues with persistent storage (e.g. OPFS).

## FAQ

### 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)