Real-time collaboration

How multiple editors work on the same document at once, without a CRDT

When two editors open the same document, each one's changes appear on the other's screen near-instantly. Cusp does this without a CRDT and without an operational-transform layer. The mechanism is one Durable Object per document — the DocumentSession DO — which is the document while it is being edited. It holds the live block array, sequences every change through the single-threaded actor model, broadcasts to connected peers over a WebSocket, and writes back to D1 on its own schedule.

The DO is the source of truth

While at least one editor is connected, the document lives inside the DocumentSession DO, not in D1. The DO is addressed by document ID, so there is exactly one instance per document and every connection to that document lands on the same actor. Because the actor model handles one message at a time, there are no race conditions to design around: every accepted operation is applied to the in-memory block array in arrival order, with a single well-defined result.

This is why no CRDT is needed. CRDT libraries exist to merge concurrent writes from peers that share no coordinator. The DO is that coordinator. Block-level granularity means concurrent edits usually land on different blocks; when two edits touch the same block, the DO applies them in arrival order, last-write-wins per field.

WebSocket hibernation

The DO uses the WebSocket Hibernation API. A DO holding idle WebSocket connections is not billed for wall-clock time: it sleeps when nobody is typing and wakes when a message arrives. That makes a session cheap to keep open all day.

The discipline that makes hibernation safe is that all state needed across a sleep lives in DO storage or is derivable from D1; in-memory fields are treated as caches. Per-connection identity is carried on the WebSocket attachment, so it survives hibernation along with the socket.

Operations

Editors send typed, block-level operations over the socket. Each operation targets a block by its stable ULID, never by index — indices are not safe under concurrent edits. There are seven operation kinds:

  • insert — add a new block after a given block, or at the head of the list.
  • update — shallow-merge a patch onto a block's fields. The id, type, and schema version are immutable through update.
  • delete — remove a block.
  • move — reposition a block after another block.
  • split — split a block at a position into a prefix and a fresh suffix block (pressing Enter in the middle of a paragraph).
  • merge — absorb one block's content into another and remove it (pressing Backspace at the start of a block).
  • transformType — replace a block's whole shape in place while preserving its ID (the slash menu turning a paragraph into a heading).

split, merge, and transformType are first-class kinds rather than derived sequences for two reasons: atomic broadcast, so a peer never observes a half-applied edit where content briefly vanishes; and identity preservation, so the version-diff view can attribute the change as a single semantic event on a stable block ID instead of an unrelated delete and create.

Each operation carries an opId — a client-generated ULID used for acknowledgement and idempotent dedup. A retried operation with a seen opId is acknowledged without being applied again. On accept the DO assigns a per-document monotonic serverSeq and returns it in the ack; the same serverSeq rides along on the broadcast so peers can detect drops. Messages are capped at 64 KiB; a larger one is rejected with OP_TOO_LARGE. A document allows up to 50 concurrent editors; the 51st upgrade is refused with a 429.

The exact op shapes, validation rules, and envelope fields are pinned in the op-protocol decision.

Wire protocol

Both directions use JSON messages. On a successful upgrade the DO sends welcome, carrying the current blocks, the connected peers, and the latest serverSeq. After that the client submits op and awareness messages (and ping, which the DO answers with pong); the DO replies with ack to the submitter, broadcasts op and awareness to peers, and emits peerLeft when a connection closes. Recoverable errors come back as error with a code; an invalid frame is rejected the same way.

{ "type": "op", "op": { "kind": "insert", "opId": "01J...", "afterId": null, "block": { "id": "01J...", "type": "core/paragraph", "v": 1, "text": "..." } } }

Awareness

Awareness is presence: who is connected and which block each editor is focused on. The client emits awareness when the focused block changes, plus a heartbeat every 5 seconds so a foregrounded tab stays visible to peers.

Awareness is broadcast-only and never persisted. It is not written to DO storage or D1, because it is meaningless once an editor disconnects. The active-block field is a soft hint, not a lock: peers render an "editing this block" indicator, but the DO does not arbitrate, and another editor may take over. Peers drop a presence from the UI if its last heartbeat is older than 15 seconds, which covers a backgrounded tab that still holds the socket but has stopped emitting.

Flush to D1

The DO does not write to the database on every keystroke. It flushes the canonical block array to D1 on three triggers, whichever comes first:

  • Idle — 2 seconds with no incoming operations. Each accepted operation reschedules a storage alarm 2 seconds out, so the flush only fires after a genuine quiet window.
  • Op count — every 50 accepted operations, flushed straight after the ack.
  • On disconnect — when the last connected editor leaves, the DO flushes immediately and clears the alarm so it can hibernate cleanly.

The flush writes through the same documents repository the admin save path uses, so it persists the draft body and spills the block array to R2 if it exceeds the 200 kB document threshold. Auto-flushes do not append a version row per flush — the version log keeps the meaningful editor-facing events (create, save, publish, unpublish, restore). The DO never has a private path to D1. Flush is idempotent: it compares the last applied operation against the last flushed one and does nothing if they match. A failed flush loses no data — the in-memory state and the operation log are untouched, and the next operation or alarm retries.

Rehydration

On a true cold start — the DO storage is empty after full eviction — the first connecting client triggers a load. The DO reads the document through the repository (the draft body if present, otherwise the published body), spilling back in from R2 if the blocks were stored there, seeds its in-memory state, and replies with welcome. Legacy block shapes are migrated to the current schema on load, so the on-disk form upgrades lazily as documents are edited.

After a hibernation, DO storage still holds the live state, so a reconnecting client receives the current block array in welcome and resumes from there. The DO also retains a bounded append-only operation log (the most recent operations) for idempotent dedup of retried ops; older entries are pruned because the materialised state is canonical.

Authentication at upgrade

The WebSocket upgrade is handled by the admin API before it reaches the DO. The route validates the session, requires an editing role (administrator, editor, or author), rejects a cross-origin handshake to guard against cross-site WebSocket hijacking, rate-limits the upgrade through the RateLimit DO, confirms the document exists, and only then forwards the upgrade to the DocumentSession DO via env.DOC_SESSION.idFromName(documentId). The authenticated user ID, display name, and a server-generated connection ID are attached as headers at upgrade time. The DO trusts those headers and never accepts identity claims from the client over the socket afterwards.

Decisions behind this

Read the versioning guide