How Real-Time Co-Editing Works in Instatic Using Yjs CRDT

Instatic implements real-time co-editing by combining Yjs CRDTs with a server-authoritative WebSocket protocol, ensuring convergent state across peers while enforcing fine-grained permissions through capability validation.

Instatic is a collaborative site builder that enables multiple users to edit pages, components, and layouts simultaneously without conflicts. The platform leverages Yjs (a Conflict-Free Replicated Data Type library) as the underlying synchronization engine, wrapped in a custom three-layer architecture that manages document lifecycle, wire protocol, and client-side state. This article examines the specific implementation details found in the CoreBunch/Instatic repository to explain how deterministic convergence and security are achieved.

Three-Layer Architecture

The real-time collaboration system is partitioned into distinct responsibilities that separate storage concerns from transport logic.

Server-Side CRDT Storage (Layer A)

The server maintains one Y-Document per logical database row—referred to as the collab document—for entities like pages, components, layouts, and sites. This layer acts as the authoritative source of truth, persisting state as JSON blobs while broadcasting updates to connected clients.

Key implementation files include:

Wire Protocol (Layer B)

All collab documents are multiplexed over a single WebSocket connection at /admin/api/cms/site-socket. The protocol defines binary frames containing a docId, generation (lineage identifier), frame type, and payload.

Frame types are defined in src/core/collab/protocol.ts:

  • FRAME_SYNC — Standard Yjs document updates
  • FRAME_AWARENESS — Cursor positions and user presence
  • FRAME_RESET — Lineage resets requiring client re-seeding
  • FRAME_PING / FRAME_PONG — Liveness checks

Client-Side Provider (Layer C)

The browser instantiates a singleton CollabProvider (defined in src/admin/pages/site/collab/collabProvider.ts) that manages the WebSocket lifecycle. It binds Y-Documents on demand, applies local updates to the CRDT, serializes changes into frames, and processes inbound messages. Remote changes are merged into the editor store via src/admin/pages/site/collab/inlineEditRemoteMerge.ts.

Collaborative Session Lifecycle

A typical editing session progresses through six distinct phases, from initial connection to ongoing synchronization.

Document Identification

Every database row maps to a unique collab document id derived from its type and primary key. The utility functions encodeCollabDocId and parseCollabDocId in src/core/collab/docIds.ts handle this encoding, ensuring consistent addressing across client and server.

Seeding and Initial Sync

When a client first binds a document via CollabProvider.bind(docId), it enters a seeding phase. The client sends a sync-step-1 frame to the server, which replies with the full document state (FRAME_SYNC). This server-authoritative approach guarantees that no two clients can diverge from the moment they connect.

// Client-side binding example
import { createCollabProvider } from '@admin/pages/site/collab/collabProvider'

const collab = createCollabProvider()
const { doc, whenSynced } = collab.bind('page:42')

await whenSynced // Wait for authoritative seed before editing

Propagating Updates

Local edits trigger Yjs update events. The provider's attachDoc method registers an updateHandler that:

  1. Serializes the update using syncProtocol.writeUpdate
  2. Transmits it via sendFrame as a FRAME_SYNC payload

On the server, socket.ts receives the frame through dispatchFrame, validates it via validateGuardedUpdate against user capabilities, then applies it using Y.applyUpdate. The server publishes the same update to all subscribers, including the originating client (making the operation idempotent).

// Server-side sync handling (server/collab/socket.ts)
if (frame.frameType === FRAME_SYNC) {
  const { doc, generation } = bound
  const messageType = decoding.readVarUint(decoding.createDecoder(frame.payload))
  
  // Capability guard for non-full writers
  if (messageType !== SYNC_STEP_1 && !ws.data.fullSiteWriter) {
    const guardDecoder = decoding.createDecoder(frame.payload)
    decoding.readVarUint(guardDecoder) // skip type
    const update = decoding.readVarUint8Array(guardDecoder)
    const verdict = validateGuardedUpdate(frame.docId, doc, update, ws.data.capabilities)
    if (!verdict.ok) {
      sendReset(ws, frame.docId, 'refused')
      return
    }
    Y.applyUpdate(doc, update, ws)
    return
  }

  // Full writers use standard Yjs sync protocol
  const encoder = encoding.createEncoder()
  syncProtocol.readSyncMessage(decoding.createDecoder(frame.payload), encoder, doc, ws)
}

Awareness and Presence

Cursor positions and user selections travel over the awareness channel using the pseudo-document id PRESENCE_DOC_ID. Clients encode local state via awarenessProtocol.encodeAwarenessUpdate and decode remote updates using awarenessProtocol.applyAwarenessUpdate. The server relays these messages without modification to all peers.

// Publishing cursor updates (collabProvider.ts)
if (origin !== REMOTE_ORIGIN) {
  const changed = [...added, ...updated, ...removed]
  if (changed.length) {
    sendFrame(
      PRESENCE_DOC_ID,
      FRAME_AWARENESS,
      awarenessProtocol.encodeAwarenessUpdate(awareness, changed),
    )
  }
}

Lineage Management and Resets

Each document carries a generation identifier tracking its lineage. If the server rewrites the underlying JSON (e.g., during a migration or administrative change), it detects outdated client lineages and transmits a FRAME_RESET frame. The ResetReason enum includes values like rewritten, stale, refused, and oversize.

Upon receiving a reset, the client:

  1. Unbinds the document
  2. Notifies local listeners
  3. Rebinds to re-seed from the server's fresh state

This mechanism prevents divergence caused by missed updates or schema changes.

Connection Health and Back-Pressure

The provider monitors connection quality through two mechanisms:

  • Heartbeat: Periodic FRAME_PING frames expecting FRAME_PONG responses
  • Back-pressure: Monitoring socket.bufferedAmount against MAX_BACKLOG_BYTES

If the buffer exceeds the threshold or the connection appears black-holed, the client marks the status as offline, closes the socket, and initiates exponential back-off reconnection logic.

Security and Policy Enforcement

Real-time collaboration requires strict validation to prevent unauthorized structural changes.

Capability-Based Update Validation

The server enforces a capability guard in server/collab/updateGuard.ts. Only users possessing all site-wide edit capabilities (site.structure.edit, site.content.edit, site.style.edit) bypass per-update validation. All other users have updates passed through validateGuardedUpdate, which mirrors the HTTP save pipeline's structural, content, and style rules.

Payload Limits and Awareness Verification

The system implements two additional safety measures:

  • Oversize protection: Sync payloads exceeding 4 MiB are rejected, triggering a reset with reason oversize
  • Identity validation: The server ensures clients cannot impersonate other users when publishing presence updates via reviewAwarenessUpdate

Summary

Instatic's real-time co-editing architecture delivers deterministic convergence through these key mechanisms:

  • Server-authoritative CRDTs: Yjs documents seeded from server state prevent initial divergence
  • Multiplexed wire protocol: Binary frames over a single WebSocket efficiently handle multiple documents and awareness channels
  • Capability guards: validateGuardedUpdate enforces fine-grained permissions on every mutation
  • Lineage tracking: Generation identifiers and FRAME_RESET messages handle server-side mutations gracefully
  • Resilient transport: Back-pressure detection and heartbeat monitoring ensure robust connectivity

Frequently Asked Questions

What happens when two users edit the same content simultaneously?

Instatic uses Yjs CRDTs to guarantee that concurrent edits converge to the same state without locking. Both clients apply local changes immediately, transmit binary diff updates via FRAME_SYNC frames, and integrate remote updates through Y.applyUpdate. The CRDT mathematics ensure that the merge order does not affect the final document state.

How does Instatic prevent unauthorized users from making structural changes?

Every incoming update passes through validateGuardedUpdate in server/collab/updateGuard.ts unless the user holds full site-write capabilities. This function checks structural, content, and style permissions against the user's capabilities before applying the Yjs update to the server document. Unauthorized changes are rejected with a FRAME_RESET bearing reason refused.

What triggers a document reset during an active session?

The server sends a FRAME_RESET when it detects a lineage mismatch (generation identifier differs), when updates exceed the 4 MiB size limit (oversize), or when validation fails (refused). The client responds by unbinding the document and re-seeding from the server's current state to ensure consistency.

How does the system handle poor network conditions?

The client monitors socket.bufferedAmount against MAX_BACKLOG_BYTES to detect back-pressure. If the buffer grows too large or heartbeat pings fail, the provider marks the connection as offline, closes the WebSocket, and attempts reconnection with exponential back-off. This prevents memory exhaustion and ensures eventual re-synchronization when connectivity returns.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →