Building a Real-Time Collaborative Code Editor with Yjs CRDTs, E2E Encryption & Cloudflare Durable Objects
Learn how to build a zero-knowledge, real-time collaborative text editor using Yjs CRDTs over WebSockets, 256-bit AES-GCM client-side encryption, Cloudflare Durable Objects as a relay with SQLite persistence, and IndexedDB for offline-first local state.
Key Architectural Takeaways
- 01.All document updates are encrypted client-side with AES-GCM 256-bit keys before leaving the browser — the Cloudflare relay is zero-knowledge and never sees plaintext.
- 02.Yjs CRDTs automatically resolve concurrent document edits without requiring central lock servers or operational transformation.
- 03.Cloudflare Durable Objects with WebSocket Hibernation consume zero CPU when idle, making thousands of concurrent rooms cost $0.
- 04.SQLite persistence inside the Durable Object lets late-joining peers catch up to the full document state without requiring any other peer to be online.
- 05.IndexedDB local persistence (via y-indexeddb) gives each client offline-first access to their room's last known document state.
1. Architecture & Threat Model Overview
Traditional collaborative platforms (like Google Docs or Notion) route every keystroke through a central database cluster. This incurs high server costs, database concurrency bottlenecks, and potential privacy risks.
DevPantry's collaborative editor uses a WebSocket relay hosted on a Cloudflare Durable Object. Every peer maintains a persistent WebSocket connection to the same Durable Object room. The DO fans out messages between all connected peers and persists encrypted update snapshots in its SQLite storage.
Crucially, all document data is encrypted client-side using AES-GCM 256-bit keys derived from the room password via PBKDF2 (100,000 iterations). The Cloudflare relay only ever sees opaque Base64 ciphertext — it acts as a zero-knowledge relay and persistence layer.
[Browser Peer A] <==== Encrypted WebSocket ====> [Cloudflare DO Room]
[Browser Peer B] <==== Encrypted WebSocket ====> (SQLite persistence)
[Browser Peer C] <==== Encrypted WebSocket ====> [Cloudflare DO Room]
All payloads: Base64(AES-GCM-256(Yjs binary update))
Cloudflare sees: zero plaintext — purely a zero-knowledge relay2. Zero-Knowledge E2E Encryption via Web Crypto API
The room password never leaves the client. On room creation or join, PBKDF2 (SHA-256, 100,000 iterations) derives a 256-bit AES-GCM CryptoKey from the password and a salt derived from the room ID. This key is never serialised or sent anywhere.
Every Yjs binary update is encrypted before being sent, and decrypted immediately after receiving. A unique 12-byte IV is generated per message and prepended to the ciphertext. If a peer connects with the wrong password, decryption throws a PayloadDecryptionError and the connection is closed — both for live updates and for the historical updates replayed from the Durable Object's SQLite store on join.
// Derive AES-GCM 256-bit key from room password + room-ID-derived salt
export async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const passwordKey = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password),
{ name: 'PBKDF2' }, false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 100_000, hash: 'SHA-256' },
passwordKey,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']
);
}
// Encrypt: prepend random 12-byte IV to ciphertext
export async function encryptPayload(data: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data));
const result = new Uint8Array(12 + cipher.length);
result.set(iv, 0);
result.set(cipher, 12);
return result;
}
// Decrypt: slice IV, then decrypt; throws PayloadDecryptionError on wrong key
export async function decryptPayload(data: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
const iv = data.slice(0, 12);
const cipher = data.slice(12);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, cipher)
.catch(() => { throw new PayloadDecryptionError(); });
return new Uint8Array(plain);
}3. Conflict Resolution using Yjs CRDTs & the Sync Protocol
When two developers type at line 42 at the exact same millisecond, standard string concatenation produces corrupted states.
We use Yjs, an open-source Conflict-free Replicated Data Type (CRDT) framework. Yjs models documents as a tree of immutable items with unique client IDs and logical clocks. When edits arrive out-of-order, Yjs deterministically converges all clients to the exact same document state without a central server authority.
To handle late-joining peers, the provider implements the y-protocols sync handshake: on connect (and on every peer-joined event), the client sends a sync-step-1 message containing its current state vector. Any peer that holds updates the newcomer is missing replies with a sync-step-2 containing those incremental updates — all encrypted. The Durable Object's SQLite store also replays its persisted updates on join via the room-init message, so a new peer catches up to the full document history even when joining an empty room.
Local edits use the provider instance as the Yjs update origin to prevent re-broadcasting updates that were received from remote peers.
// Encrypt and broadcast every local Yjs update to the room.
// origin === this means the update came from a remote peer — skip to avoid echo.
private async handleLocalDocUpdate(update: Uint8Array, origin: any) {
if (origin === this) return;
const encrypted = await encryptPayload(update, this.cryptoKey);
this.send({
type: 'sync-update',
peerId: this.localPeerId,
payload: buffer.toBase64(encrypted),
});
// Every 50 local updates, send a full snapshot so the DO can compact its SQLite log
if (++this.localUpdatesSinceSnapshot >= 50) {
this.localUpdatesSinceSnapshot = 0;
this.sendFullSnapshot();
}
}
// On connect and on peer-joined: broadcast our state vector so peers can diff
private async sendSyncStep1() {
const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, 0); // messageSync
syncProtocol.writeSyncStep1(encoder, this.doc);
const encrypted = await encryptPayload(encoding.toUint8Array(encoder), this.cryptoKey);
this.send({ type: 'sync-step-1', peerId: this.localPeerId, payload: buffer.toBase64(encrypted) });
}4. Zero-Cost WebSocket Relay with Cloudflare Durable Objects
Each collaboration room is a single Cloudflare Durable Object instance. Peers connect to it via WebSocket at /api/collab/:roomId?peerId=<uuid>.
The DO uses the WebSocket Hibernation API (this.ctx.acceptWebSocket(server)): idle WebSockets consume zero CPU, making thousands of concurrent rooms essentially free. It wakes up only when a message arrives.
On connect, the DO sends a room-init message containing the list of already-connected peers and all encrypted updates stored in SQLite — allowing the new peer to reconstruct the full document history immediately. On subsequent updates, it broadcasts sync-update messages to all peers in the room and persists a running log of encrypted snapshots. When a snapshot is received, older update entries are compacted.
Ping/pong heartbeats (every 20 seconds, with a 10-second pong timeout) detect stale connections. If the pong timeout fires, the client triggers an exponential-backoff reconnect (up to 5 attempts, starting at 1s).
export class EncryptedCollabProvider {
private ws: WebSocket | null = null;
private autoRetryCount = 0;
// MAX_AUTO_RETRIES = 5, BASE_BACKOFF_MS = 1000
// HEARTBEAT_INTERVAL_MS = 20000, PONG_TIMEOUT_MS = 10000
public connect() {
const wsUrl = `wss://${host}/api/collab/${this.roomId}?peerId=${this.localPeerId}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.autoRetryCount = 0;
this.startHeartbeat();
this.flushQueue(); // replay messages queued while offline
this.sendSyncStep1(); // request any updates we missed
this.broadcastLocalAwareness();
};
this.ws.onclose = () => {
this.cleanupHeartbeat();
if (!this.intentionalDisconnect) this.handleUnexpectedDisconnect();
};
}
private handleUnexpectedDisconnect() {
if (this.autoRetryCount < 5) {
const delay = 1000 * Math.pow(2, this.autoRetryCount++); // 1s, 2s, 4s, 8s, 16s
this.setState('RECONNECTING');
this.reconnectTimer = setTimeout(() => this.connect(), delay);
} else {
this.setState('FAILED');
}
}
}