# Cloudflare Durable Object adapter

The Cloudflare Durable Object adapter enables running LiveStore applications on Cloudflare Workers with stateful Durable Objects for synchronized real-time data.

## Installation

```bash
pnpm add @livestore/adapter-cloudflare @livestore/sync-cf
```

## Configuration

### Wrangler configuration

Configure your `wrangler.toml` with the required Durable Object bindings:

```toml
name = "my-livestore-app"
main = "./src/worker.ts"
compatibility_date = "2025-05-07"
compatibility_flags = [
  "enable_request_signal", # Required for HTTP RPC streams
]

[[durable_objects.bindings]]
name = "SYNC_BACKEND_DO"
class_name = "SyncBackendDO"

[[durable_objects.bindings]]
name = "CLIENT_DO"
class_name = "LiveStoreClientDO"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["SyncBackendDO", "LiveStoreClientDO"]

[[d1_databases]]
binding = "DB"
database_name = "my-livestore-db"
database_id = "your-database-id"
```

### Environment types

Define your Worker bindings so TypeScript can guide you when wiring Durable Objects:


## `reference/platform-adapters/cloudflare/env.ts`

```ts filename="reference/platform-adapters/cloudflare/env.ts"
import type { ClientDoWithRpcCallback } from '@livestore/adapter-cloudflare'
import type { CfTypes, SyncBackendRpcInterface } from '@livestore/sync-cf/cf-worker'

export type Env = {
  CLIENT_DO: CfTypes.DurableObjectNamespace<ClientDoWithRpcCallback>
  SYNC_BACKEND_DO: CfTypes.DurableObjectNamespace<SyncBackendRpcInterface>
  DB: CfTypes.D1Database
}
```

We also use a small helper to extract the store identifier from incoming requests:


## `reference/platform-adapters/cloudflare/shared.ts`

```ts filename="reference/platform-adapters/cloudflare/shared.ts"
import type { CfTypes } from '@livestore/sync-cf/cf-worker'

export const storeIdFromRequest = (request: CfTypes.Request) => {
  const url = new URL(request.url)
  const storeId = url.searchParams.get('storeId')

  if (storeId === null) {
    throw new Error('storeId is required in URL search params')
  }

  return storeId
}
```

## Basic setup

### 1. Create the sync backend Durable Object

The sync backend handles pushing and pulling events between clients:


## `reference/platform-adapters/cloudflare/sync-backend.ts`

```ts filename="reference/platform-adapters/cloudflare/sync-backend.ts"
import * as SyncBackend from '@livestore/sync-cf/cf-worker'

export class SyncBackendDO extends SyncBackend.makeDurableObject({
  // Optional: Handle push events
  // onPush: async (message, { storeId }) => {
  //   console.log(`onPush for store (${storeId})`, message.batch)
  // },
}) {}
```

### 2. Create the client Durable Object

Each client Durable Object hosts a LiveStore instance and exposes DO RPC callbacks:


## `reference/platform-adapters/cloudflare/client-do.ts`

```ts filename="reference/platform-adapters/cloudflare/client-do.ts"
/// <reference types="@cloudflare/workers-types" />

import { DurableObject } from 'cloudflare:workers'

import { type ClientDoWithRpcCallback, createStoreDoPromise } from '@livestore/adapter-cloudflare'
import { nanoid, type Store, type Unsubscribe } from '@livestore/livestore'
import { handleSyncUpdateRpc } from '@livestore/sync-cf/client'

import type { Env } from './env.ts'
import { schema, tables } from './schema.ts'
import { storeIdFromRequest } from './shared.ts'

type AlarmInfo = {
  isRetry: boolean
  retryCount: number
}

export class LiveStoreClientDO extends DurableObject<Env> implements ClientDoWithRpcCallback {
  override __DURABLE_OBJECT_BRAND: never = undefined as never

  private storeId: string | undefined
  private cachedStore: Store<typeof schema> | undefined
  private storeSubscription: Unsubscribe | undefined
  private readonly todosQuery = tables.todos.select()

  override async fetch(request: Request): Promise<Response> {
    // @ts-expect-error TODO remove casts once CF types are fixed in https://github.com/cloudflare/workerd/issues/4811
    this.storeId = storeIdFromRequest(request)

    const store = await this.getStore()
    await this.subscribeToStore()

    const todos = store.query(this.todosQuery)
    return new Response(JSON.stringify(todos, null, 2), {
      headers: { 'Content-Type': 'application/json' },
    })
  }

  private async getStore() {
    if (this.cachedStore !== undefined) {
      return this.cachedStore
    }

    const storeId = this.storeId ?? nanoid()

    const store = await createStoreDoPromise({
      schema,
      storeId,
      clientId: 'client-do',
      sessionId: nanoid(),
      durableObject: {
        // @ts-expect-error TODO remove once CF types are fixed in https://github.com/cloudflare/workerd/issues/4811
        ctx: this.ctx,
        env: this.env,
        bindingName: 'CLIENT_DO',
      },
      syncBackendStub: this.env.SYNC_BACKEND_DO.get(this.env.SYNC_BACKEND_DO.idFromName(storeId)),
      livePull: true,
    })

    this.cachedStore = store
    return store
  }

  private async subscribeToStore() {
    const store = await this.getStore()

    if (this.storeSubscription === undefined) {
      this.storeSubscription = store.subscribe(this.todosQuery, (todos: ReadonlyArray<typeof tables.todos.Type>) => {
        console.log(`todos for store (${this.storeId})`, todos)
      })
    }

    await this.ctx.storage.setAlarm(Date.now() + 1000)
  }

  override alarm(_alarmInfo?: AlarmInfo): void | Promise<void> {
    return this.subscribeToStore()
  }

  async syncUpdateRpc(payload: Uint8Array<ArrayBuffer>, storeId: string) {
    this.storeId = storeId
    await this.getStore()
    // @ts-expect-error TODO remove once CF types are fixed in https://github.com/cloudflare/workerd/issues/4811
    await handleSyncUpdateRpc(this.ctx, payload)
  }
}
```

### `reference/platform-adapters/cloudflare/env.ts`

```ts filename="reference/platform-adapters/cloudflare/env.ts"
import type { ClientDoWithRpcCallback } from '@livestore/adapter-cloudflare'
import type { CfTypes, SyncBackendRpcInterface } from '@livestore/sync-cf/cf-worker'

export type Env = {
  CLIENT_DO: CfTypes.DurableObjectNamespace<ClientDoWithRpcCallback>
  SYNC_BACKEND_DO: CfTypes.DurableObjectNamespace<SyncBackendRpcInterface>
  DB: CfTypes.D1Database
}
```

### `reference/platform-adapters/cloudflare/schema.ts`

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

export const tables = {
  todos: State.SQLite.table({
    name: 'todos',
    columns: {
      id: State.SQLite.text({ primaryKey: true }),
      text: State.SQLite.text({ default: '' }),
      completed: State.SQLite.boolean({ default: false }),
      deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromMillis }),
    },
  }),
}

export const events = {
  todoCreated: Events.synced({
    name: 'v1.TodoCreated',
    schema: Schema.Struct({ id: Schema.String, text: Schema.String }),
  }),
  todoCompleted: Events.synced({
    name: 'v1.TodoCompleted',
    schema: Schema.Struct({ id: Schema.String }),
  }),
  todoUncompleted: Events.synced({
    name: 'v1.TodoUncompleted',
    schema: Schema.Struct({ id: Schema.String }),
  }),
  todoDeleted: Events.synced({
    name: 'v1.TodoDeleted',
    schema: Schema.Struct({
      id: Schema.String,
      deletedAt: Schema.DateFromString,
    }),
  }),
  todoClearedCompleted: Events.synced({
    name: 'v1.TodoClearedCompleted',
    schema: Schema.Struct({ deletedAt: Schema.DateFromString }),
  }),
}

const materializers = State.SQLite.materializers(events, {
  'v1.TodoCreated': ({ id, text }) => tables.todos.insert({ id, text, completed: false }),
  'v1.TodoCompleted': ({ id }) => tables.todos.update({ completed: true }).where({ id }),
  'v1.TodoUncompleted': ({ id }) => tables.todos.update({ completed: false }).where({ id }),
  'v1.TodoDeleted': ({ id, deletedAt }) => tables.todos.update({ deletedAt }).where({ id }),
  'v1.TodoClearedCompleted': ({ deletedAt }) => tables.todos.update({ deletedAt }).where({ completed: true }),
})

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

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

### `reference/platform-adapters/cloudflare/shared.ts`

```ts filename="reference/platform-adapters/cloudflare/shared.ts"
import type { CfTypes } from '@livestore/sync-cf/cf-worker'

export const storeIdFromRequest = (request: CfTypes.Request) => {
  const url = new URL(request.url)
  const storeId = url.searchParams.get('storeId')

  if (storeId === null) {
    throw new Error('storeId is required in URL search params')
  }

  return storeId
}
```

### 3. Worker fetch handler

The worker routes incoming requests either to the sync backend or to the client Durable Object:


## `reference/platform-adapters/cloudflare/worker.ts`

```ts filename="reference/platform-adapters/cloudflare/worker.ts"
import type { CfTypes } from '@livestore/sync-cf/cf-worker'
import * as SyncBackend from '@livestore/sync-cf/cf-worker'

import type { Env } from './env.ts'
import { storeIdFromRequest } from './shared.ts'

export default {
  fetch: async (request: CfTypes.Request, env: Env, ctx: CfTypes.ExecutionContext) => {
    const url = new URL(request.url)

    const searchParams = SyncBackend.matchSyncRequest(request)
    if (searchParams !== undefined) {
      return SyncBackend.handleSyncRequest({
        request,
        searchParams,
        env,
        ctx,
        syncBackendBinding: 'SYNC_BACKEND_DO',
        headers: {},
      })
    }

    if (url.pathname.endsWith('/client-do') === true) {
      const storeId = storeIdFromRequest(request)
      const id = env.CLIENT_DO.idFromName(storeId)
      return env.CLIENT_DO.get(id).fetch(request)
    }

    return new Response('Not found', { status: 404 }) as unknown as CfTypes.Response
  },
} satisfies SyncBackend.CFWorker<Env>
```

### `reference/platform-adapters/cloudflare/env.ts`

```ts filename="reference/platform-adapters/cloudflare/env.ts"
import type { ClientDoWithRpcCallback } from '@livestore/adapter-cloudflare'
import type { CfTypes, SyncBackendRpcInterface } from '@livestore/sync-cf/cf-worker'

export type Env = {
  CLIENT_DO: CfTypes.DurableObjectNamespace<ClientDoWithRpcCallback>
  SYNC_BACKEND_DO: CfTypes.DurableObjectNamespace<SyncBackendRpcInterface>
  DB: CfTypes.D1Database
}
```

### `reference/platform-adapters/cloudflare/shared.ts`

```ts filename="reference/platform-adapters/cloudflare/shared.ts"
import type { CfTypes } from '@livestore/sync-cf/cf-worker'

export const storeIdFromRequest = (request: CfTypes.Request) => {
  const url = new URL(request.url)
  const storeId = url.searchParams.get('storeId')

  if (storeId === null) {
    throw new Error('storeId is required in URL search params')
  }

  return storeId
}
```

## API reference

### `createStoreDoPromise(options)`

Creates a LiveStore instance inside a Durable Object.

**Options:**
- `schema` – LiveStore schema definition
- `storeId` – Unique identifier for the store
- `clientId` – Client identifier
- `sessionId` – Session identifier (use `nanoid()`)
- `durableObject` – Context about the Durable Object hosting the store:
  - `state` – Durable Object state handle (for example `this.ctx`)
  - `env` – Environment bindings for the Durable Object
  - `bindingName` – Name other workers use to reach this Durable Object
- `syncBackendStub` – Durable Object stub used to reach the sync backend
- `livePull` – Enable real-time updates (default: `false`)
- `params?` – Advanced per-client runtime tuning, including `stateRebuildBatchSize`
- `resetPersistence` – Drop LiveStore state/eventlog persistence before booting (development only, default: `false`)
- `logger?` – Optional Effect logger layer to customize formatting/output
- `logLevel?` – Optional minimum log level (use `"None"` to disable logs)

### `syncUpdateRpc(payload, storeId)`

Client Durable Objects must implement this method so the sync backend can deliver live updates to them. Forward the DO's `ctx` and the `payload` to `handleSyncUpdateRpc(ctx, payload)` (see the client Durable Object example above).

Cloudflare can evict a Durable Object at any time and start a fresh instance on the next request. Because this call is what wakes the DO, it can arrive at an instance that no longer has your store in memory — and a fresh instance can't tell which `storeId` it belongs to on its own. That's why the sync backend also passes the `storeId`. You choose how to use it:

- **Eager (stay in sync):** load the store before delivering — set `this.storeId = storeId`, then `await this.getStore()` (idempotent, and it catches up on any updates the DO missed while it was gone). The DO stays in sync whenever it is awake and costs nothing while idle.
- **Reactive (recover lazily):** ignore `storeId` (you can omit the parameter). The DO drops that one live update and picks up the changes the next time your code loads the store (e.g. the next `fetch`). Simpler, but live updates are not applied while the DO is idle.

Pass `storeId` to your store load (`getStore`), not to `handleSyncUpdateRpc` — that function only needs `ctx` and `payload`.

### Live-pull subscription lifecycle

With `livePull: true`, booting a store registers a live-pull subscription on the sync backend so updates can arrive via `syncUpdateRpc`. Shutting the store down (`store.shutdown()`) releases it, and the sync backend stops delivering to — and waking — the Durable Object.

Eviction is deliberately different: Cloudflare discards the isolate without running any cleanup, so an evicted Durable Object keeps its subscription. That retention is what lets the next update wake it and drive recovery (see the options above); the subscription is never dropped on silence, so a hibernating client never loses its place.

### Schema changes and billing

Changing the state schema causes each affected store to open a fresh state database and replay its full eventlog, even if only one table changed. On Durable Objects, rebuilding the state can incur billable storage writes. Plan deployments according to the eventlog size and number of store instances.

Rebuilds use 100 events per batch by default. A Durable Object can set `params: { stateRebuildBatchSize }` on `createStoreDo` or `createStoreDoPromise`; the value must be a positive integer. Lower values can reduce per-batch resource use, but require more queries, savepoints, and storage writes. This limit counts events rather than bytes and does not guarantee that the whole isolate stays within its memory limit, so profile representative data and materializers. Other clients of the same store can use different values.

The adapter tracks state files it opens. After successful boot, it removes older tracked files while preserving the current database, eventlog and untracked files. Failed rebuilds retain previous state. Cleanup failures log a warning and retry on the next successful boot, including when reusing completed state. Files from older releases remain untouched unless subsequently opened and tracked. Returning to a schema whose state was removed rebuilds it from the eventlog.

Cleanup itself consumes row-write quota. It prevents new orphan accumulation, but cannot recover storage that is already too full to complete a rebuild.

## Resetting LiveStore persistence (development only)

When iterating locally, you can instruct the adapter to wipe the Durable Object’s LiveStore databases before booting by enabling `resetPersistence`. Guard this behind a protected route or admin token.


## `reference/platform-adapters/cloudflare/reset.ts`

```ts filename="reference/platform-adapters/cloudflare/reset.ts"
import { createStoreDoPromise } from '@livestore/adapter-cloudflare'
import { nanoid } from '@livestore/livestore'
import type { CfTypes } from '@livestore/sync-cf/cf-worker'

import type { Env } from './env.ts'
import { schema } from './schema.ts'

export const maybeResetStore = async ({
  request,
  env,
  ctx,
}: {
  request: Request
  env: Env
  ctx: CfTypes.DurableObjectState
}) => {
  const url = new URL(request.url)
  const shouldReset = url.pathname === '/internal/livestore-dev-reset'

  const storeId = url.searchParams.get('storeId') ?? nanoid()

  const store = await createStoreDoPromise({
    schema,
    storeId,
    clientId: 'client-do',
    sessionId: nanoid(),
    durableObject: { ctx, env, bindingName: 'CLIENT_DO' },
    syncBackendStub: env.SYNC_BACKEND_DO.get(env.SYNC_BACKEND_DO.idFromName(storeId)),
    livePull: true,
    resetPersistence: shouldReset,
  })

  return store
}
```

### `reference/platform-adapters/cloudflare/env.ts`

```ts filename="reference/platform-adapters/cloudflare/env.ts"
import type { ClientDoWithRpcCallback } from '@livestore/adapter-cloudflare'
import type { CfTypes, SyncBackendRpcInterface } from '@livestore/sync-cf/cf-worker'

export type Env = {
  CLIENT_DO: CfTypes.DurableObjectNamespace<ClientDoWithRpcCallback>
  SYNC_BACKEND_DO: CfTypes.DurableObjectNamespace<SyncBackendRpcInterface>
  DB: CfTypes.D1Database
}
```

### `reference/platform-adapters/cloudflare/schema.ts`

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

export const tables = {
  todos: State.SQLite.table({
    name: 'todos',
    columns: {
      id: State.SQLite.text({ primaryKey: true }),
      text: State.SQLite.text({ default: '' }),
      completed: State.SQLite.boolean({ default: false }),
      deletedAt: State.SQLite.integer({ nullable: true, schema: Schema.DateFromMillis }),
    },
  }),
}

export const events = {
  todoCreated: Events.synced({
    name: 'v1.TodoCreated',
    schema: Schema.Struct({ id: Schema.String, text: Schema.String }),
  }),
  todoCompleted: Events.synced({
    name: 'v1.TodoCompleted',
    schema: Schema.Struct({ id: Schema.String }),
  }),
  todoUncompleted: Events.synced({
    name: 'v1.TodoUncompleted',
    schema: Schema.Struct({ id: Schema.String }),
  }),
  todoDeleted: Events.synced({
    name: 'v1.TodoDeleted',
    schema: Schema.Struct({
      id: Schema.String,
      deletedAt: Schema.DateFromString,
    }),
  }),
  todoClearedCompleted: Events.synced({
    name: 'v1.TodoClearedCompleted',
    schema: Schema.Struct({ deletedAt: Schema.DateFromString }),
  }),
}

const materializers = State.SQLite.materializers(events, {
  'v1.TodoCreated': ({ id, text }) => tables.todos.insert({ id, text, completed: false }),
  'v1.TodoCompleted': ({ id }) => tables.todos.update({ completed: true }).where({ id }),
  'v1.TodoUncompleted': ({ id }) => tables.todos.update({ completed: false }).where({ id }),
  'v1.TodoDeleted': ({ id, deletedAt }) => tables.todos.update({ deletedAt }).where({ id }),
  'v1.TodoClearedCompleted': ({ deletedAt }) => tables.todos.update({ deletedAt }).where({ completed: true }),
})

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

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

:::caution
Resetting persistence deletes all LiveStore state and eventlog data stored inside the Durable Object. Only expose this behaviour in guarded development flows and never to production traffic.
:::

## Advanced features

- Use `livePull: true` to receive push-based updates via Durable Object RPC callbacks.
- Subscribe to data changes inside the Durable Object to trigger side effects (see the client Durable Object example).
- Wire additional routes in the worker fetch handler to expose debugging endpoints or admin operations.

For sync backend-related APIs like `makeDurableObject`, `handleSyncRequest`, and `matchSyncRequest`, see the [Cloudflare sync provider documentation](/sync-providers/cloudflare).