Accepted

ADR 0014 — DocumentSession Durable Object lifecycle

How the per-document Durable Object holds, sequences, flushes, and rehydrates collaborative editing state — with no CRDT

Collaborative editing in Cusp runs through a per-document Durable Object called DocumentSession. While editors are connected it is the source of truth for that document: it holds the block array, sequences block-level operations through the actor model, and broadcasts them to peers. When idle it costs almost nothing because it uses WebSocket Hibernation.

The decision

One Durable Object per document, addressed by the document ID. It accepts WebSocket connections, serialises every operation, broadcasts to connected peers, and periodically flushes the materialised block array to D1 through the same documents repository the admin API uses. The actor model gives single-threaded ordering for free, so there is no CRDT and no operational transform.

Transport and hibernation

The DO uses WebSocket Hibernation. Idle connected clients do not bill compute time. Anything that must survive hibernation lives in Durable Object storage or is derivable from D1; in-memory class fields are caches only. The lifecycle constants are a 50-operation flush threshold, a 2-second idle flush window, a 64 KiB maximum WebSocket message, a 50-peer cap per document, and a 200-entry operation-log cap.

Persistence layout

Durable Object storage holds three things:

  • state — the current document: the block array plus the last applied operation ID. Updated on every accepted operation.
  • log entries — an append-only operation log keyed by operation ID. It backs idempotent op de-duplication on retries, and the most recent entries are retained for replay when reconnect-with-replay lands. Entries past the 200-entry cap are pruned oldest-first after each flush.
  • meta — the document ID, flush bookkeeping, the operations-since-flush counter, the last flushed operation ID, and the per-document server sequence counter.

D1 is the canonical store. Flushes write the block array through the documents repository, which routes it to the draft or published body column and spills to R2 past the 200 KiB threshold. Auto-flushes use the session-persist path, so they do not append an entity_versions row per flush — the version log keeps only meaningful editor-facing events (create, save, publish, unpublish, restore).

Flush mechanics

A flush fires on whichever of three conditions comes first:

  • Operation count — every accepted operation increments a counter; at 50 the handler flushes after acknowledging.
  • Idle window — every accepted operation schedules an alarm 2 seconds out, replacing any prior alarm, so the flush only fires after a true 2-second quiet window.
  • Last disconnect — when the final connected client leaves, the DO flushes immediately and clears the alarm so it can hibernate cleanly.

Flush is idempotent: if the last applied operation already matches the last flushed operation, it returns without a D1 write. A failed flush loses nothing — the in-memory state and the operation log are untouched, and the next operation or alarm retries. If the document was deleted while the DO held it, the flush treats the not-found error as a soft failure, clears the alarm to avoid a hot loop, and leaves the unflushed state in place.

Rehydration

A cold-start DO with no storage loads the document from D1 (draft body if present, otherwise the published body), seeds state, and sends a welcome message carrying the full block array, the last server sequence, and the current peers. A warm-start DO after hibernation already has its state in storage. A reconnecting client today receives the full block array on welcome and is treated as a fresh joiner; reconnect-with-replay over the operation log is the planned optimisation the retained log is sized for. Loading goes through the documents repository — there is no DO-specific D1 access.

Auth and rate limiting

The WebSocket upgrade is gated before it reaches the DO. The session is validated and the editor's role is checked (administrator, editor, or author); the upgrade origin must match the request host, closing the cross-site WebSocket hijacking hole that SameSite=Lax cookies would otherwise leave; the document must exist; and the upgrade is rate-limited per user via the RateLimit Durable Object. Only then is the upgrade forwarded to the DO with server-set headers carrying the user ID, display name, and a server-generated client ID. The DO trusts those upgrade headers and never accepts identity claims from the client over the socket itself.

Awareness is never persisted

Presence — cursor, selection, the block someone is editing — is broadcast to peers and held only on the WebSocket attachment so it survives hibernation; it is never written to Durable Object storage or D1. On disconnect the DO emits a peer-left message and forgets the client. Clients re-emit their awareness when they (re)connect.

Discarding a draft

Discarding a draft is the one non-WebSocket control path on the DO. After the discard-draft route nulls the draft columns in D1, it POSTs to the DO's /reset endpoint. The DO drops its in-memory draft buffer, re-syncs state to the live published body, clears the operation log, cancels any pending flush alarm, and broadcasts a reset envelope so connected editors snap to the published body and clear their pending op queue. Without this, the next flush would re-persist the discarded draft.

Why no CRDT

Single-tenant, single-document editing through one actor removes the problem a CRDT solves. The actor model serialises message handling, so two operations on the same block apply in arrival order — last-write-wins per block, no merge. Yjs and Automerge were rejected: they pull significant client bundle weight, complicate conflict semantics editors never asked for, and tie the data model to a CRDT representation. A DO per block was rejected because a multi-block reorder would need cross-DO coordination the actor model does not give cheaply.

Consequences

The collaborative primitive sits behind the existing API surface, is hibernation-friendly, and is the foundation the block editor wires through. Operation-level CRUD by stable block ID is the right shape for the block library — every block type slots in without a protocol change. The flush thresholds are tuneable from the meta record without a schema change. True character-level concurrent editing inside a single span is the one case left deferred; it is the only thing that would ever justify revisiting the no-CRDT stance.

Read the block model decision