# 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: unknown) {
    await handleSyncUpdateRpc(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.DateFromNumber }),
    },
  }),
}

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.Date }),
  }),
  todoClearedCompleted: Events.synced({
    name: 'v1.TodoClearedCompleted',
    schema: Schema.Struct({ deletedAt: Schema.Date }),
  }),
}

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`)
- `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 `LogLevel.None` to disable logs)

### `syncUpdateRpc(payload)`

Client Durable Objects must implement this method so the sync backend can deliver live updates. `createStoreDoPromise` wires it up automatically—just forward the payload to `handleSyncUpdateRpc` (see the client Durable Object example above).

## 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.DateFromNumber }),
    },
  }),
}

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.Date }),
  }),
  todoClearedCompleted: Events.synced({
    name: 'v1.TodoClearedCompleted',
    schema: Schema.Struct({ deletedAt: Schema.Date }),
  }),
}

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