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

> Explore how Instatic achieves real-time collaborative editing using Yjs CRDT. Discover the architecture separating document modeling, WebSocket transport, and server relay.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: deep-dive
- Published: 2026-08-01

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/collab/schema.ts) |
| **Client transport** | Single WebSocket multiplexing all documents, frame protocol encoding, provider API | [`src/admin/pages/site/collab/collabProvider.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/collab/collabProvider.ts) |
| **Server relay & persistence** | Binary blob storage, update validation, peer broadcast, reseeding | [`server/collab/socket.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/collab/socket.ts), [`server/repositories/collabDocuments.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/collab/schema.ts). The schema exports accessor functions that guarantee consistent document structure across client and server:

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/schema.ts) abstract this access:

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/applySitePatches.ts), [`textDiff.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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.

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/server/collab/socket.ts) and [`server/repositories/collabDocuments.ts`](https://github.com/CoreBunch/Instatic/blob/main/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**:

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/collab/textDiff.ts) translates these into Yjs `Y.Text` operations while preserving CRDT semantics:

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/site/collab/collabProvider.ts) accepts an optional WebSocket factory for testing, and the frame protocol in [`src/core/collab/protocol.ts`](https://github.com/CoreBunch/Instatic/blob/main/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.