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:
src/core/collab/index.ts— Barrel exports for the CRDT engineserver/collab/socket.ts— WebSocket entry point for frame dispatchserver/collab/relay.ts— Document retain/release and subscription relay management
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 updatesFRAME_AWARENESS— Cursor positions and user presenceFRAME_RESET— Lineage resets requiring client re-seedingFRAME_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:
- Serializes the update using
syncProtocol.writeUpdate - Transmits it via
sendFrameas aFRAME_SYNCpayload
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:
- Unbinds the document
- Notifies local listeners
- 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_PINGframes expectingFRAME_PONGresponses - Back-pressure: Monitoring
socket.bufferedAmountagainstMAX_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:
validateGuardedUpdateenforces fine-grained permissions on every mutation - Lineage tracking: Generation identifiers and
FRAME_RESETmessages 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →