---
title: Your own Durable Object
description: Host a document in any Durable Object with attachDocument, load it from and save it to your own storage (R2, KV, D1), and edit it on the server.
icon: puzzle
---

`DocumentRoom` is a ready-made Durable Object. When you already have one, or want your own storage and server logic, attach the document to it instead:

```ts src/worker.ts
import { DurableObject } from 'cloudflare:workers';
import { attachDocument, routeDocumentSocket } from 'edytor/cloudflare';

export class Notes extends DurableObject<Env> {
  document = attachDocument(this, {
    onLoad: () => loadFromMyStore(this.ctx.id.name!), // { update, replicas }
    onSave: ({ update, replicas }) => saveToMyStore(this.ctx.id.name!, { update, replicas })
  });
}

export default {
  fetch(request, env) {
    const id = new URL(request.url).pathname.split('/').pop()!;
    return routeDocumentSocket(request, env.NOTES, id, authorize);
  }
} satisfies ExportedHandler<Env>;
```

That is the whole server. `routeDocumentSocket` works with any namespace whose objects host a document, and [authorization](/docs/server/authorization) is unchanged.

:::note
This `attachDocument` comes from `edytor/cloudflare` and hosts a document in a Durable Object. The `attachDocument` exported by `edytor` wraps an engine document in the browser or Node. They are different functions.
:::

## What `attachDocument(this, options)` does

- **Storage.** The document lives in the object's SQLite storage, in two tables named `edytor_rows` and `edytor_replicas`, beside your own tables. Every edit is stored there before it is acknowledged, whatever your hooks do. `tablePrefix` changes the prefix.
- **Sockets.** The document's sockets carry the tag `edytor` (`SOCKET_TAG`). Other sockets of the object are yours.
- **Handlers.** Each handler your class does not define is installed on the object: `fetch`, `webSocketMessage`, `webSocketClose`, `webSocketError`, and `alarm` when you pass `onSave`.
- **Return value.** An `AttachedDocument` with the same handlers, plus `transact`, `read`, `facade` and `compact`.

Call it once, in a field or the constructor.

### Alongside your own handlers

A class that defines a handler keeps it and delegates to the document. The document's socket handlers return `false` for sockets that are not its own:

```ts
export class Workspace extends DurableObject<Env> {
  document = attachDocument(this, { onSave: (saved) => this.persist(saved) });

  async fetch(request: Request) {
    if (new URL(request.url).pathname.startsWith('/events')) return this.openEventStream(request);
    return this.document.fetch(request); // the document's upgrade
  }

  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    if (this.document.webSocketMessage(ws, message)) return;
    // …your own sockets
  }

  async alarm() {
    await this.document.alarm(); // runs onSave
    // …your own scheduled work
  }
}
```

A Durable Object has one alarm. The document sets it `saveAfter` ms after an unsaved edit, which replaces a time you set earlier. An alarm already pending when the object wakes is kept: the document does not move it, so frequent wakes never push `onSave` back. If your class also uses alarms, re-arm your own schedule from `alarm()`, and call `document.alarm()` from it.

## Options

| Option | Default | Description |
| --- | --- | --- |
| `onLoad` | | Returns the document for a room that stores nothing yet. See [Load](#load). |
| `onSave` | | Receives `{ value, update, replicas }` after edits. See [Save](#save). |
| `saveAfter` | `2000` | ms between the first unsaved edit and `onSave`. |
| `compactAfter` | `500` | Stored updates before they are merged into one snapshot. |
| `maxRowBytes` | 1,995,904 | Largest stored row. Can only be lowered. |
| `maxFrameBytes` | 32 MiB | Largest frame sent whole; larger ones are chunked. Can only be lowered. |
| `tablePrefix` | `'edytor_'` | Prefix of the document's two tables. |
| `semantics` | `defaultSemantics` | The block roles server edits obey. See [Block roles on the server](#block-roles-on-the-server). |

`DocumentRoom` reads the same settings from `vars` (`EDYTOR_SAVE_AFTER`, `EDYTOR_COMPACT_AFTER`, …; see [the room](/docs/server/room#settings)) and uses the unprefixed tables `rows` and `replicas`. It takes `semantics` from its `semantics()` method, read at first use, so it may return a field of your subclass.

## Load

`onLoad()` runs when the object starts and stores nothing yet, before any socket is served. It may be async. Return:

| Return | Effect |
| --- | --- |
| `{ update, replicas }` | What an earlier `onSave` received: the v14 state and who owns which client id. Restores both. |
| `Uint8Array` | A bare v14 update. Restores the content but not the owners: each client id that holds content is unowned until a signed-in writer dials with it as its replica (logged as an `orphan` entry). |
| `JSONDoc` | Seeded as the document. Seeding is deterministic, so a client that seeds the same value converges with it. |
| `undefined` / `null` | An empty room; the first client seeds it. |

Nothing is stored until `onLoad` settles, and the result is stored in one transaction, so the room is never left half-loaded:

- **Nothing returned** is provisional: `onLoad` is asked again at every start until something is stored. A KV read that is not yet visible is retried at the next start.
- **A throw** refuses sockets with `1011` (their providers retry), and the next dial or start asks `onLoad` again.
- **A refused payload** (another generation or schema, undecodable bytes, any other shape) sets `failure` and refuses every socket with `1008` (`refused: container`). The object keeps answering.

Once anything is stored, the object restores from its own storage and does not ask again.

A saved copy can be older than the room's last edits (it is written `saveAfter` ms after them). Clients that reconnect after such a restore deliver what the room lacks, including other users' edits they received: nobody is disconnected for it, and nobody takes over another user's client id. See [restoring from an older snapshot](/docs/server/authorization#restoring-from-an-older-snapshot).

## Save

`onSave({ value, update, replicas })` runs `saveAfter` ms after the first edit it has not saved, on the object's alarm, so it does not keep the object from hibernating. If it throws, the platform retries the alarm with backoff. If the alarm fires while the room cannot read its rows (a storage fault), it reads them again first; if that fails too, the save stays due and the alarm is set again, backing off up to 5 minutes, so `onSave` runs once the room recovers. Without `onSave`, no alarm is ever set.

| Field | Content |
| --- | --- |
| `update` | The full CRDT state. Store it to load the document back. |
| `replicas` | Who owns each client id (`{ replica, user }[]`), read with `update`. Store it beside `update` and return both from `onLoad`, so returning clients can write again after a restore. See [replicas](/docs/server/authorization#replicas). |
| `value` | The document as JSON, for search, previews or exports. |

:::warning
Load from `update`, not from `value`. JSON starts a new document without the shared history: if the room's storage were ever reset and seeded from JSON, a client still holding the old history offline would merge both and could duplicate content.
:::

## Storage recipes

The room's name is `this.ctx.id.name`: rooms are opened with `getByName(documentId)`, and Cloudflare keeps the name for the object's lifetime, including when an alarm wakes it.

**R2**

```ts
document = attachDocument(this, {
  onLoad: async () => {
    const [object, owners] = await Promise.all([
      this.env.DOCS.get(`${this.ctx.id.name}.bin`),
      this.env.DOCS.get(`${this.ctx.id.name}.replicas.json`)
    ]);
    if (!object) return undefined;
    const update = new Uint8Array(await object.arrayBuffer());
    return owners ? { update, replicas: await owners.json<ReplicaOwner[]>() } : update;
  },
  onSave: async ({ value, update, replicas }) => {
    await this.env.DOCS.put(`${this.ctx.id.name}.bin`, update);
    await this.env.DOCS.put(`${this.ctx.id.name}.replicas.json`, JSON.stringify(replicas));
    await this.env.DOCS.put(`${this.ctx.id.name}.json`, JSON.stringify(value)); // optional
  }
});
```

R2 has no practical size limit: the best fit for documents. `ReplicaOwner` is exported by `edytor/cloudflare`; a copy saved before `replicas` existed still loads as a bare update.

**KV**

```ts
document = attachDocument(this, {
  onLoad: async () => {
    const name = this.ctx.id.name!;
    const bytes = await this.env.DOCS_KV.get(name, 'arrayBuffer');
    const replicas = await this.env.DOCS_KV.get<ReplicaOwner[]>(`${name}:replicas`, 'json');
    if (!bytes) return undefined;
    return replicas ? { update: new Uint8Array(bytes), replicas } : new Uint8Array(bytes);
  },
  onSave: async ({ update, replicas }) => {
    await this.env.DOCS_KV.put(this.ctx.id.name!, update);
    await this.env.DOCS_KV.put(`${this.ctx.id.name}:replicas`, JSON.stringify(replicas));
  }
});
```

A KV value holds at most 25 MiB, and other locations may read an old value for a short while. `onLoad` only runs for a room that stores nothing, and a missing value is asked again at the next start, so that rarely matters here.

**D1**

```ts
document = attachDocument(this, {
  onLoad: async () => {
    const row = await this.env.DB.prepare('SELECT state, replicas FROM documents WHERE id = ?')
      .bind(this.ctx.id.name)
      .first<{ state: ArrayBuffer; replicas: string }>();
    return row ? { update: new Uint8Array(row.state), replicas: JSON.parse(row.replicas) } : undefined;
  },
  onSave: async ({ value, update, replicas }) => {
    await this.env.DB.prepare(
      'INSERT OR REPLACE INTO documents (id, state, replicas, json) VALUES (?, ?, ?, ?)'
    )
      .bind(this.ctx.id.name, update, JSON.stringify(replicas), JSON.stringify(value))
      .run();
  }
});
```

A D1 row holds at most 2 MB; use R2 for larger documents.

## Edit on the server

`transact(fn)` runs `fn` with the document's [facade](/docs/reference/document-api) in one transaction, stores the edit and broadcasts it to every connected socket, like a client's edit. It returns what `fn` returns. Expose it through your own RPC methods:

```ts
import { toBlockSpec } from 'edytor/crdt/edytor';

export class Notes extends DurableObject<Env> {
  document = attachDocument(this);

  appendNote(text: string) {
    return this.document.transact((doc) =>
      doc.insertBlock(
        { parent: null, index: doc.childrenIds(null).length },
        toBlockSpec({ type: 'paragraph', content: [{ text }] })
      )
    );
  }
}

// From a Worker, a queue consumer, a cron trigger…
await env.NOTES.getByName(documentId).appendNote('Deployed v2');
```

`read()` returns the document as JSON. A room with no document yet is seeded with one empty block before `transact` runs.

### Block roles on the server

The server has no plugins, so it takes the block roles it checks from `semantics`: which kinds are void or islands, which render no content, and each container's default child. The default, `defaultSemantics` from `edytor/crdt/edytor`, holds the kinds of the bundled rich-text, code and image plugins. Server edits obey them as a view does: merging into a divider, splitting an image or moving a code line out of its code block is `refused`.

If your clients' plugins add void or island kinds, or redefine bundled ones, pass the same rules:

```ts
import { defaultSemantics } from 'edytor/crdt/edytor';

document = attachDocument(this, {
  semantics: { ...defaultSemantics, roles: { ...defaultSemantics.roles, embed: { void: true } } }
});
```

In a `DocumentRoom` subclass, override `protected semantics()` and return the config. `semantics: {}` checks no roles.

## Subclassing `DocumentRoom`

The ready-made room exposes the same hooks as methods:

```ts src/room.ts check
import { DocumentRoom, type ReplicaOwner, type SavedDocument } from 'edytor/cloudflare';

export class Room extends DocumentRoom<Env> {
  protected override async onLoad() {
    const name = this.ctx.id.name;
    const [object, owners] = await Promise.all([
      this.env.DOCS.get(`${name}.bin`),
      this.env.DOCS.get(`${name}.replicas.json`)
    ]);
    if (!object) return undefined;
    const update = new Uint8Array(await object.arrayBuffer());
    return owners ? { update, replicas: await owners.json<ReplicaOwner[]>() } : update;
  }

  protected override async onSave({ update, replicas }: SavedDocument) {
    await this.env.DOCS.put(`${this.ctx.id.name}.bin`, update);
    await this.env.DOCS.put(`${this.ctx.id.name}.replicas.json`, JSON.stringify(replicas));
  }
}
```

Bind and migrate the subclass by its own class name (`"class_name": "Room"`, `"new_sqlite_classes": ["Room"]`). If it defines `alarm()`, call `await super.alarm()` from it. `transact`, `read`, `compact` and `reset` are methods of the room itself.
