Skip to content
Edytor
Esc
↑↓navigate↵open⌘Jpreview
On this page

The room

What the DocumentRoom Durable Object enforces, how it stores and acknowledges edits, and the limits to plan for.

DocumentRoom is a Durable Object that coordinates one document: deploy one object per document, reached through routeDocumentSocket. Everything on this page also holds for a document attached to your own Durable Object with attachDocument; 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). 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. The socket stays open.
  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.
  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), which is final too. A dial with a replica another user owns is accepted, then closed with 4409 (replica bound to another user), also final.

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 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.
  • After EDYTOR_COMPACT_AFTER update records (500 by default), the room replaces all rows with one merged snapshot, 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 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.
  • The client-id registrations used for identity are stored next to the document, and handed to onSave 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 the room now holds. The provider turns these into its saved and unsaved 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) obey the block roles of the bundled rich-text, code and image plugins (defaultSemantics): the same edit a view refuses, such as merging into a divider or moving a code line out of its code block, is refused on the server. Rooms whose clients define other void or island kinds pass their own rules; see 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.
{
  "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.

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:

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.

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, and an internal entry an error of the engine or a send. Two entries are not refusals: orphan is an unowned id claimed by its dial (after a restore without a registry, or a relay), and relayed is content under an id the room held nothing of, delivered by another replica and kept unowned (see replicas).

Generation cutover

A room refuses storage written by another edytor generation (see 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.
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 at most 256 characters, and so are user ids.
  • 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.

For the frame format, or to write a room of your own, see the protocol.

Was this page helpful?