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

> Discover how Instatic achieves real-time co-editing with Yjs CRDTs and WebSockets. Learn about convergent state and robust permission enforcement for seamless collaboration.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: how-to-guide
- Published: 2026-07-31

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/collab/index.ts) — Barrel exports for the CRDT engine
- [`server/collab/socket.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/collab/socket.ts) — WebSocket entry point for frame dispatch
- [`server/collab/relay.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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).

```typescript
// 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.

```typescript
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.