---
title: The room
description: What the DocumentRoom Durable Object enforces, how it stores and acknowledges edits, and the limits to plan for.
icon: server
---

`DocumentRoom` is a Durable Object that coordinates one document: deploy one object per document, reached through [`routeDocumentSocket`](/docs/server/authorization). Everything on this page also holds for a document attached to your own Durable Object with [`attachDocument`](/docs/server/extending); only its table names differ (`edytor_rows`, `edytor_replicas`). This page describes what the room checks on every message, how it stores the document, and the settings and limits you can tune.

## One room per document

`routeDocumentSocket(request, env.ROOMS, documentId, authorize)` opens the room named `documentId` (`env.ROOMS.getByName(documentId)`). The room keeps the live document in memory, stores it in the object's SQLite storage, and relays edits and presence between the sockets connected to it. Clients are the ordinary `createWebsocketSync` or `WebsocketProvider`.

## Admission

Every frame a socket sends is checked in this order. A refused frame is never applied, stored or relayed.

1. **Generation.** The frame's first word must be this build's generation (see [the protocol](/docs/server/protocol#frames)). A client of another edytor generation is refused before anything is decoded.
2. **Access.** An edit from a read-only socket is dropped with a `permission-denied` reply (`read-only`). The socket stays open. When it joins, it gets a read-only notice instead (auth subtype `1`), which is not a refusal.
3. **Identity.** Content an update adds under a client id owned by another user, or under an unregistered id that already has content, is stripped from the frame, and the rest of the frame is applied: the sender stays connected. See [replicas](/docs/server/authorization#replicas).
4. **Schema.** An update that writes a foreign schema stamp is refused. The same check covers updates waiting for a missing dependency: when an update would release a waiting one that carries a forged stamp, the waiting updates are discarded, the new update is applied, and its sender stays connected. Honest updates discarded this way come back at their writer's next sync.

A refused socket is closed with code `1008` and reason `refused: <reason>` (`generation`, `replica`, `schema`, `malformed`, `identity`, `container`). Only bytes the room cannot decode, a text frame other than the keepalive `ping`, or an unknown message type count as `malformed`. A refusal is final: the provider stops dialing instead of reconnecting. A dial `authorize` denies never reaches the room: `routeDocumentSocket` closes it with `4403` (see [authorization](/docs/server/authorization#authorize)), which is final too, and so is `4400` (`invalid document id`: empty, `.`, `..`, over 256 characters or with a lone surrogate, closed before `authorize` runs). `authorize` returning `{ expired: true }` closes the dial with `4401`, the one `4xxx` code the provider retries, with the `params` it re-reads. A dial with a replica another user owns is accepted, then closed with `4409` (`replica bound to another user`), also final. A dial your own Worker turns away before `routeDocumentSocket` gets the same kind of close from `closedSocket(code, reason)`, never an HTTP error, which the browser reports as `1006` and the provider redials.

A fault of the room itself is never a refusal. A storage error (writing a client-id registration, say) closes the socket with `1011` and reason `storage failure`; an error of the CRDT engine or of a send closes it with `1011` and reason `internal error`, after the live document is rebuilt from the stored rows. The provider redials, and the handshake resends what the room does not hold. A fault that persists (a full store, an update the engine cannot apply) does not make it redial every 100 ms: each `1011` doubles its backoff, and emits `unreachable`, until the room acknowledges its edits again.

## Identity and presence

- The socket's identity (`user`, `replica`, `readOnly`) is saved in the socket's attachment, so it survives hibernation.
- Presence is relayed without an `Awareness` instance (its timer would keep the object awake). A socket may publish presence only for its own client id; entries for other ids are dropped.
- A new socket receives the presence of everyone connected. A socket that closes without saying goodbye is announced as gone, also after the room woke from hibernation.
- An accepted presence entry is also sent back to its sender. A client alone in a room therefore hears from it at every presence renewal and is never closed as silent.
- Keepalive: the provider sends a text `ping` after 15 seconds of silence, and the room answers `pong` through the runtime's WebSocket auto-response (`ctx.setWebSocketAutoResponse`), without waking the object. The room installs the pair when it starts, unless your object already set one; it then answers `ping` from its message handler. The auto-response applies to every socket of the object, yours included.
- The presence snapshot lives in memory. After a wake it refills as clients renew their presence, every 15 seconds.

## Storage

The room stores the document in its own SQLite tables:

- Every integrated update is appended as one record, split into rows under the platform's 2 MB row limit, in one transaction. Only then is it broadcast to the other sockets.
- A delete of an item the room does not hold yet (a deletion of text a restore lost, for example) is stored as a waiting record and applies when the item arrives. The exception is the attribute value that an attribute change of the same update replaces while that change itself still waits for a missing dependency: that delete waits with the change, in memory, so the attribute is never emptied. If a later update would make such a delete remove the schema stamp, every waiting delete is discarded (the room also drops a pending forged stamp that way). Every update the room applies re-reads the waiting deletes, so at most `MAX_WAITING_DELETES` (1,024) ranges wait: a frame whose deletes of unheld items would pass it has those deletes dropped, logged as a `waiting` refusal, and the rest of the frame applied. That includes deletes of the frame's own items that wait (for a missing origin) and the attribute values its waiting changes replace: when they take the room past the limit, all of the frame's new waiting deletes are dropped the same way. The acknowledgement leaves the dropped deletes out, so the sender's `saved` stays `false` and its next connection resends them; until one is stored, the deleted content shows if its author returns. Any writer may delete any content, but storing deletes that wait adds durability and a per-update cost, which this bound caps. A waiting delete lasts until its item arrives, but compaction reclaims those of a client id that no user registered and no open socket holds (a made-up id: nothing can deliver its items), in memory too; the registrations of ids with waiting deletes are kept. Deletes of a registered id's items are kept, as they may be a relayer's deletes of text its author will bring back, so a writer can still fill the limit with deletes of its own or another user's future items. Frames past the limit then keep logging `waiting` refusals: call [`dropWaitingDeletes()`](#compaction-over-rpc) to empty it. An author missing from the restored registry (a snapshot saved before that author first dialed, or a bare `update` with no `replicas`) is unknown until it dials again, so a compaction before then reclaims the deletes of that author's text. Waiting deletes arm [`onSave`](/docs/server/extending#save) like any edit, and a room restored from its `onSave` copy stores the waiting deletes it carries as a waiting record again, so they are acknowledged and reclaimed the same way.
- After `EDYTOR_COMPACT_AFTER` update records (500 by default), the room replaces all rows with one merged snapshot (plus one record of the deletes still waiting: those that applied, and those reclaimed, are dropped), atomically, once the message that made it due is acknowledged. A compaction that fails is logged as a `storage` refusal and retried later; the edit is already stored and nobody is disconnected.
- Memory never runs ahead of storage. If an append fails, nothing is relayed or acknowledged, the live document is rebuilt from the stored rows as a restart would, and the sender's socket is closed with `1011`. Its provider reconnects and the handshake resends the edit. If the append of the room's own edit fails ([`transact`](/docs/server/extending#edit-on-the-server)), `transact` throws the storage error and the edit is lost: nothing resends it. The room rebuilds from its rows too, but unlike after a client's failed append it keeps the updates it holds waiting for a missing dependency. A rebuild replaces the room's `doc` and `facade`. If reading the rows fails, during that rebuild or when the room starts, it has no document until a dial reads them again: sockets are closed with `1011` (`room unavailable`), their providers redial, and each dial retries the read.
- A woken room rebuilds the document from its rows before it accepts any message. A room whose rows read but cannot be restored (another edytor generation, a torn record) refuses every socket with `1008` and reason `refused: container`, and the object keeps answering. See [generation cutover](#generation-cutover).
- The client-id registrations used for [identity](/docs/server/authorization#replicas) are stored next to the document, and handed to [`onSave`](/docs/server/extending#save) with it. Compaction drops the registrations of ids that hold no content and belong to no open socket (a page that loaded and never wrote); such an id registers again at its next dial or write.

## Store before acknowledge

Every sync message a client sends is answered, after the room has stored what it integrated, with a *saved* frame: the room's state vector and, when the message deleted content, the deletes of that message the room stored, applied or waiting for their items (never one it dropped or keeps in memory only). The provider turns these into its [`saved` and `unsaved` state](/docs/collaboration/websocket#saved-state). A client shows "saved" only for what is on disk.

A client that catches up receives what the room has stored, never updates still waiting for a missing dependency.

## Catch-up of large documents

Cloudflare limits a WebSocket message to 32 MiB. A frame the room sends that is larger than `EDYTOR_MAX_FRAME_BYTES` (32 MiB by default) goes out as a sequence of chunks, and the provider applies it only once the sequence is complete. Smaller frames are sent whole, so clients without chunk support still sync small documents.

## Hibernation

The room schedules no timers: each entry point runs with `setTimeout` and `setInterval` disabled (the exported `noTimers` helper), because a Durable Object with a pending timer cannot hibernate. Sockets stay connected while the object sleeps; identity comes back from the socket attachments and the document from storage.

## Block roles

Edits the room makes itself ([`transact`](/docs/server/extending#edit-on-the-server)) obey the block roles of the bundled rich-text, code and image plugins (`defaultSemantics`): an edit those roles refuse in a view, such as merging into a divider, moving a code line out of its code block or merging a code block's first line into it, is refused on the server. Keyboard policies a plugin adds on top (Delete before a code block removes an empty block and does nothing otherwise) belong to the view: the server applies the plain operation. Rooms whose clients define other void or island kinds pass their own rules; see [Block roles on the server](/docs/server/extending#block-roles-on-the-server). Client edits are relayed as they come: the room does not check their roles.

## Settings

Set these as `vars` in `wrangler.jsonc`. All are optional.

| Variable | Default | Description |
| --- | --- | --- |
| `EDYTOR_MAX_ROW_BYTES` | 1,995,904 | Largest stored row. Can only be lowered. |
| `EDYTOR_MAX_FRAME_BYTES` | 33,554,432 (32 MiB) | Largest frame sent whole; larger ones are chunked. Can only be lowered. |
| `EDYTOR_COMPACT_AFTER` | 500 | Update records before the room compacts its rows into one snapshot. |
| `EDYTOR_SAVE_AFTER` | 2000 | ms between the first unsaved change and [`onSave`](/docs/server/extending#save). |

```jsonc wrangler.jsonc
{
  "vars": { "EDYTOR_COMPACT_AFTER": "200" }
}
```

Values that are not positive integers are ignored. Pass your own `Env` type to the class if you extend it: `DocumentRoom<Env>`. With `attachDocument`, pass the same settings as options. To load, save or edit the document from your own code, see [Your own Durable Object](/docs/server/extending).

## Compaction over RPC

`compact()` is also callable over Durable Object RPC, for example from an admin route or your own scheduled job. It returns the number of rows left:

```ts
const { rows } = await env.ROOMS.getByName(documentId).compact();
```

Compaction merges every stored update into one snapshot. It does not discard history the CRDT needs, so it never loses an edit.

`dropWaitingDeletes()`, also over RPC, drops every [waiting delete](#storage), stored or kept in memory with a waiting attribute change, and returns how many ranges it dropped. Use it when `waiting` refusals show the limit is full. A dropped delete's content shows again if its author returns; an attribute value a waiting change replaces is still replaced if that change applies.

```ts
const { ranges } = await env.ROOMS.getByName(documentId).dropWaitingDeletes();
```

## Diagnostics

`refusals` holds the newest 100 refusals (`MAX_REFUSALS`) of the running instance, each with its `reason` and `detail`; `refusalCounts` counts every refusal by reason. Both live in memory and restart empty; read them inside the object, or return them from an RPC method of your own. A `storage` entry is a failed append, compaction or registration, an `internal` entry an error of the engine or a send, and a `waiting` entry the deletes of unheld items a frame carried past `MAX_WAITING_DELETES` (`{ user, ranges }`; the rest of the frame applied). Two entries are not refusals: `orphan` is an unowned id claimed by its dial (after a [restore without a registry](/docs/server/extending#load), or a relay), and `relayed` is content under an id the room held nothing of, delivered by another replica and kept unowned (see [replicas](/docs/server/authorization#replicas)).

## Generation cutover

A room refuses storage written by another edytor generation (see [migration](/docs/reference/migration)); bytes are never converted in place. To move a document across:

1. Before deploying the new version, make sure `onSave` has mirrored the document as JSON (`value`).
2. Deploy. Rooms of the old generation refuse every socket (`1008`, `refused: container`); `failure` is a `GenerationMismatchError`.
3. Call `reset()` on the room, for example from an admin route. It deletes both tables in one transaction and starts again from `onLoad`.
4. Have `onLoad` return the saved JSON. The room seeds it as the new generation's document.

```ts
await env.ROOMS.getByName(documentId).reset();
```

`reset()` throws for any other room: it only replaces a container of another generation.

## Limits

- **Client to room messages are capped at 32 MiB** by the platform. Clients do not chunk what they send, so a single edit or an offline backlog larger than that cannot be delivered.
- **Document ids** are 1 to 256 characters, not `.` or `..`, with no lone surrogate; user ids are 1 to 256 characters with no lone surrogate.
- **Presence** refills over up to 15 seconds after the room wakes.
- **One document per room.** Rooms do not share state; listing, searching or aggregating documents is up to your application.
- **No auth rules ship with it.** `authorize` is yours; see [Authorization](/docs/server/authorization).

For the frame format, or to write a room of your own, see [the protocol](/docs/server/protocol).
