How Instatic Handles Real-Time Collaborative Editing with Yjs CRDT: A Deep Dive into the Architecture

Instatic implements real-time co-editing by building a thin Yjs-based CRDT layer (@core/collab) that separates document modeling, WebSocket transport, and server relay into three distinct concerns.

Real-time collaborative editing is notoriously difficult to get right. Instatic, an open-source site builder from CoreBunch, solves this challenge by leveraging Yjs—a battle-tested CRDT (Conflict-free Replicated Data Type) library—wrapped in a custom architecture that ensures convergence, integrity, and deterministic persistence. This article examines how Instatic handles real-time collaborative editing with Yjs CRDT, walking through the code from document schema to production WebSocket relay.

Architecture Overview: Three-Layer Design

The Instatic collaboration stack is intentionally thin. Rather than reimplementing CRDT semantics, it builds abstractions around Yjs's proven algorithms:

Layer Responsibility Main Modules
Document model One Y document per logical row (page, component, layout, site) with deterministic shape src/core/collab/schema.ts
Client transport Single WebSocket multiplexing all documents, frame protocol encoding, provider API src/admin/pages/site/collab/collabProvider.ts
Server relay & persistence Binary blob storage, update validation, peer broadcast, reseeding server/collab/socket.ts, server/repositories/collabDocuments.ts

This separation lets Instatic swap transport mechanisms or persistence backends without touching CRDT logic.

Document Schema: Deterministic Y-Doc Layout

Every collaborative document in Instatic follows a strict schema defined in src/core/collab/schema.ts. The schema exports accessor functions that guarantee consistent document structure across client and server:

// Y-Doc shape used by every collab document
export const treeMap = (doc: Y.Doc) => doc.getMap('tree')
export const metaMap = (doc: Y.Doc) => doc.getMap('meta')
export const dataMap = (doc: Y.Doc) => doc.getMap('data')
export const shellMap = (doc: Y.Doc) => doc.getMap('shell')
export const rostersMap = (doc: Y.Doc) => doc.getMap('rosters')

Document types map to distinct structures:

  • Tree docs (page / component): meta + tree maps. The tree holds a rootNodeId and a Y.Map of node entries.
  • Layout docs: meta + data (a snapshot JSON).
  • Site doc: shell + rosters (metadata for the whole site).

Inline Text Editing with Y.Text

For collaborative text editing—where multiple users might type simultaneously—Instatic stores inline-editable string fields as Y.Text at the character level. The inlineTextPropOf and nodeTextOf helpers in schema.ts abstract this access:

// 2️⃣ Perform an inline edit on a node property
import { nodeTextOf } from '@core/collab'

const nodeId = 'node-42'
const prop = 'title'                     // must be an inline-text prop
const yText = nodeTextOf(doc, nodeId, prop)

if (yText) {
  // Insert text at the current cursor position
  yText.insert(0, 'Hello world')
}

Y.Text provides automatic character-level merge semantics, ensuring that concurrent edits from different users converge correctly without operational transform (OT) complexity.

Transaction Origins: Distinguishing Edit Sources

Instatic tags every Yjs transaction with an origin so the client can determine what is undoable. These constants live in src/core/collab/schema.ts:

Origin Meaning
LOCAL_ORIGIN Edits performed by the current user (undo-able).
REMOTE_ORIGIN Updates received from the server (never undo-able).
SEED_ORIGIN Initial construction from persisted JSON (server-only seed).

This tagging appears throughout the translator (applySitePatches.ts, textDiff.ts) and the provider. When the undo stack processes transactions, it filters by LOCAL_ORIGIN, preventing users from accidentally undoing remote collaborators' work.

Client-Side Provider: WebSocket Management and Binding

The CollabProvider in src/admin/pages/site/collab/collabProvider.ts is the sole entry point for collaborative features. Exported by createCollabProvider(), it exposes a clean API while managing connection complexity internally.

WebSocket Lifecycle

  • Single socket: One WebSocket connection to SITE_SOCKET_PATH handles all bound documents via multiplexing.
  • Binding: bind(docId) returns { doc, synced, whenSynced }. The promise resolves after the first SYNC_STEP_1 from the server.
  • Server as source of truth: The client never seeds a document locally—always waits for server state.
// 1️⃣ Create a collab provider (client-side)
import { createCollabProvider } from '@core/collab'

const collab = createCollabProvider()
const docId = encodeCollabDocId('page', '123')   // e.g. "page:123"
const { doc, whenSynced } = collab.bind(docId)

// Wait for the initial sync before editing
await whenSynced

Outbound Updates and Backpressure

Every local Yjs transaction (origin ≠ REMOTE_ORIGIN) becomes a binary update frame:

  • Frames are queued via sendFrame.
  • Back-log guard (MAX_BACKLOG_BYTES) prevents data loss when the browser discards full buffers.
  • Exponential back-off on connection failure.

Heartbeat and Presence

  • PING/PONG frames detect "black-holed" sockets.
  • Awareness data (cursor positions, selections) travels on a dedicated presence doc (PRESENCE_DOC_ID) using y-protocols/awareness.

Server Relay: Validation and Persistence

The server implementation in server/collab/socket.ts and server/repositories/collabDocuments.ts handles the authoritative side of synchronization.

Document Storage

Each Y document maps to one database row, stored as a binary blob:

// 4️⃣ Server-side: read a collab document from the DB
import { getCollabDoc } from 'server/repositories/collabDocuments'
import * as Y from 'yjs'

async function loadPageDoc(pageId: string) {
  const raw = await getCollabDoc('page', pageId)   // binary blob
  const doc = new Y.Doc()
  Y.applyUpdate(doc, raw)
  return doc
}

Update Guarding

Before persisting or broadcasting, inbound updates pass through validation:

  • applySitePatchesToDocs applies structural patches.
  • reconcileTreeIntegrity runs a pure-JSON walk over the projected tree, fixing structural invariants (missing children, invalid references) without mutating Yjs transaction origins.

This prevents malformed or malicious operations from corrupting shared state.

Reset Handling

When the server rewrites a document's JSON snapshot (e.g., after an admin restore), it sends a FRAME_RESET. Clients:

  1. Unbind the old document.
  2. Reseed from fresh JSON.
  3. Continue editing with converged state.

Text Diffs and Integrity Reconciliation

Applying Plain-Text Diff Operations

UI toolbar commands often generate plain-text patches. The applyTextDiff helper in src/core/collab/textDiff.ts translates these into Yjs Y.Text operations while preserving CRDT semantics:

// 3️⃣ Apply a plain-text diff (e.g. from a toolbar command)
import { applyTextDiff } from '@core/collab'

applyTextDiff(doc, nodeId, prop, [
  { retain: 5 },
  { insert: ' new' },
  { delete: 3 },
])

This bridges imperative UI operations with Yjs's functional update model.

Structural Integrity

reconcileTreeIntegrity in src/core/collab/integrity.ts performs deterministic cleanup on the projected JSON tree. Because it operates on pure JSON rather than Yjs structures, it can fix invariant violations without origin side effects.

End-to-End Synchronization Flow

  1. Server initialization: Creates Y-docs for each row (seedPageDoc, seedComponentDoc, etc.) and stores binary blobs.

  2. Client connection: createCollabProvider() opens WebSocket to SITE_SOCKET_PATH.

  3. Document binding: provider.bind(docId) sends SYNC_STEP_1; server replies with SYNC_STEP_2 containing current state.

  4. First sync: Client applies state to fresh Y.Doc; whenSynced resolves.

  5. Live editing: Local edits → Yjs updates → binary frames → server validation → persistence → broadcast to peers.

  6. Remote merge: Yjs automatically merges concurrent updates; provider forwards changes to editor store; UI updates.

Because Yjs guarantees eventual convergence, all participants see the same tree. The integrity step ensures the persisted JSON representation stays well-formed for non-collaborative reads.

Summary

Instatic's real-time collaborative editing with Yjs CRDT demonstrates how to build production-ready co-editing without reinventing distributed systems:

  • Deterministic schema: Fixed Y-doc layout via schema.ts accessors ensures client-server consistency.
  • Origin tagging: LOCAL_ORIGIN, REMOTE_ORIGIN, and SEED_ORIGIN enable precise undo semantics.
  • Server authority: Clients never seed locally; server validates all updates through applySitePatchesToDocs and reconcileTreeIntegrity.
  • Resilience: Back-log guards, heartbeats, and exponential back-off handle real-world network conditions.
  • Performance: Single multiplexed WebSocket, binary encoding, and lazy document binding minimize overhead.

Frequently Asked Questions

How does Instatic prevent users from undoing each other's changes?

Instatic tags every Yjs transaction with an origin constant from src/core/collab/schema.ts. The undo stack filters for LOCAL_ORIGIN only, excluding REMOTE_ORIGIN (other users' edits) and SEED_ORIGIN (server initialization). This ensures undo operations affect only the current user's work.

Why does Instatic store documents as binary blobs rather than JSON?

Yjs documents are stored as binary blobs in server/repositories/collabDocuments.ts because Yjs's binary update format preserves complete CRDT metadata—operation clocks, deletion sets, and tombstones—that JSON cannot represent. This enables accurate merge semantics when clients reconnect after offline periods.

What happens when the server restores a document from backup?

The server sends a FRAME_RESET to all connected clients. Per collabProvider.ts, clients unbind the stale document, reseed from the fresh JSON snapshot, and resume editing. This ensures all collaborators converge to the restored state without manual intervention.

Can Instatic's collab layer work with other transport protocols?

Yes. The CollabProvider in src/admin/pages/site/collab/collabProvider.ts accepts an optional WebSocket factory for testing, and the frame protocol in src/core/collab/protocol.ts is transport-agnostic. Replacing WebSocket with WebRTC datachannels or server-sent events would require only adapter changes, preserving the Yjs CRDT core.

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 →