Building a Self-Hosted Real-Time Collaborative Code Review Platform with CRDTs and WebRTC
Quick Summary (TL;DR) This guide walks you through building a peer-to-peer, self-hosted code review platform using CRDTs (Conflict-free Replicated Data Types) for automatic conflict resolution and WebRTC for direct browser-to-browser sync. You’ll set up a dev environment, implement a CRDT layer with Yjs, wire up WebRTC data channels, sync diffs and live comments, handle conflicts, and deploy securely — all without a central application server.
Introduction to Peer-to-Peer Code Review
Traditional code review tools like GitHub PRs, GitLab, or Gerrit all rely on a central server that owns the source of truth. That’s fine for public repos, but it creates friction: latency, hosting costs, and a single point of trust. What if reviewers could connect directly, edit diffs and leave comments in real time, and automatically reconcile everyone’s changes — even if some peers are offline?
That’s the promise of a peer-to-peer (P2P) collaborative code review platform. In this guide, we’ll build one from scratch using CRDTs for state reconciliation and WebRTC for transport. The result is a self-hosted, low-latency tool where each browser is both client and server.
Why CRDTs and WebRTC for Collaborative Code Review?
Two problems dominate any real-time collaborative editor:
- Convergence — how do concurrent edits from multiple peers end up in the same final state?
- Transport — how do peers talk to each other directly without a relay?
CRDTs solve the first problem mathematically. Unlike Operational Transformation (OT), which requires a central sequencer, CRDTs are commutative and idempotent — apply operations in any order and every replica converges. WebRTC solves the second by giving browsers a native data channel over UDP.
| Approach | Central Server | Conflict Handling | Offline Support | Latency |
|---|---|---|---|---|
| OT (Google Docs) | Required | Server-sequenced | Poor | Low |
| CRDT + WebSocket | Required relay | Automatic | Excellent | Low |
| CRDT + WebRTC | Optional | Automatic | Excellent | Lowest |
The combination gives you offline-first editing, sub-100ms sync on LAN, and no mandatory backend.
Architecture Overview: Decentralized Code Review
Our platform has three layers: a CRDT document model, a WebRTC transport, and a UI layer. A lightweight signaling server is only used for the initial handshake — after that, peers connect directly.
flowchart TD
A[Peer A - Browser] -->|Signaling: offer/answer| S[Signaling Server]
B[Peer B - Browser] -->|Signaling: offer/answer| S
A <-->|WebRTC DataChannel| B
C[Peer C - Browser] <-->|WebRTC DataChannel| A
C <-->|WebRTC DataChannel| B
A --> D[Yjs CRDT Doc]
B --> D
C --> D
D --> E[IndexedDB Persistence]
Each peer maintains a full replica of the CRDT document. The signaling server never sees document content — it only brokers ICE candidates and SDP offers.
Setting Up the Development Environment
We’ll use Vite for fast dev builds, Yjs for CRDTs, and y-webrtc for the transport layer.
npm create vite@latest p2p-review -- --template vanilla-ts
cd p2p-review
npm install yjs y-webrtc y-indexeddb monaco-editor diff
npm install -D vitest playwright
Directory layout:
src/
crdt/ # Yjs document schema
webrtc/ # Peer connection + signaling
ui/ # Monaco diff editor, comment threads
sync/ # Diff reconciliation
Run npm run dev and open two browser tabs — you’ll simulate two peers throughout this guide.
Implementing the CRDT Layer for Code and Comments
Yjs gives us shared types: Y.Text for code, Y.Array for comment threads, and Y.Map for metadata. Here’s the core document schema:
import * as Y from 'yjs';
export function createReviewDoc() {
const doc = new Y.Doc();
return {
doc,
code: doc.getText('code'), // shared source file
comments: doc.getArray('comments'), // ordered comment list
metadata: doc.getMap('meta'), // PR title, author, etc.
cursors: doc.getMap('cursors'), // live cursor positions
};
}
Each comment is a Y.Map so individual fields (author, body, resolved) can be updated independently:
function addComment(comments: Y.Array<any>, anchor: number, body: string) {
const comment = new Y.Map();
comment.set('anchor', anchor);
comment.set('body', body);
comment.set('resolved', false);
comment.set('timestamp', Date.now());
comments.push([comment]);
}
Because Y.Array preserves insertion order and Y.Map merges keys by last-writer-wins with a deterministic tiebreaker (client ID), concurrent comment additions never collide.
Establishing WebRTC Connections for Real-Time Sync
y-webrtc wraps the signaling and peer management. You provide a signaling server URL (public ones exist, but for self-hosting you’ll run your own — see the deployment section).
import { WebrtcProvider } from 'y-webrtc';
const { doc } = createReviewDoc();
const provider = new WebrtcProvider('review-room-42', doc, {
signaling: ['wss://signal.yourdomain.com'],
password: 'shared-secret', // encrypts signaling payloads
maxConns: 20,
});
The handshake sequence looks like this:
sequenceDiagram
participant A as Peer A
participant S as Signaling Server
participant B as Peer B
A->>S: Join room "review-room-42"
B->>S: Join room "review-room-42"
S->>A: Peer B joined
A->>B: SDP Offer
B->>A: SDP Answer
A->>B: ICE Candidates
B->>A: ICE Candidates
Note over A,B: DTLS handshake complete
A<<->>B: Yjs sync messages over DataChannel
Once the DTLS handshake completes, all document updates flow directly. The signaling server can go offline and peers stay connected.
Synchronizing Diffs and Live Comments
For code review, we need to display a diff between two revisions while allowing live edits. Use diff to compute hunks, then render them in Monaco’s side-by-side editor. Every keystroke updates Y.Text, which broadcasts a tiny binary delta.
import { diffLines } from 'diff';
function renderDiff(original: string, modified: string) {
const hunks = diffLines(original, modified);
return hunks.map(h => ({
type: h.added ? 'add' : h.removed ? 'remove' : 'context',
lines: h.value.split('\n'),
}));
}
// Live comment anchoring: use Yjs relative positions
import { createRelativePositionFromTypeIndex } from 'yjs';
function anchorComment(code: Y.Text, index: number) {
return createRelativePositionFromTypeIndex(code, index);
}
Relative positions are the secret sauce — they survive concurrent edits. If Peer A inserts text above a comment anchor while Peer B is typing, the anchor shifts automatically.
Conflict Resolution Strategies with CRDTs
CRDTs resolve conflicts automatically, but “automatic” doesn’t always mean “what you wanted.” Understand the semantics:
| Data Type | Merge Rule | Best For |
|---|---|---|
Y.Text | Interleaved insert, RGA order | Code, prose |
Y.Array | Insertion order preserved | Comment lists |
Y.Map | Last-writer-wins per key | Metadata, flags |
Y.XmlFragment | Tree-aware | Rich text |
For code review specifically, you’ll want semantic guards on top of raw CRDTs:
- Reject overwrites of resolved comments — use a
Y.Mapflag and check before applying. - Detect semantic conflicts — if two peers edit the same function signature, flag it in the UI even though the CRDT merged cleanly.
- Undo manager — Yjs ships
Y.UndoManagerscoped to a single peer’s changes:
import { UndoManager } from 'yjs';
const undo = new UndoManager([comments, code], { trackedOrigins: new Set([provider]) });
Building the User Interface for Collaborative Review
The UI has three panes: file tree, diff view (Monaco), and comment sidebar. Live cursors use Y.Map keyed by client ID:
provider.awareness.setLocalStateField('user', {
name: 'Alice',
color: '#e91e63',
cursor: { line: 42, column: 8 },
});
provider.awareness.on('change', () => {
const states = provider.awareness.getStates();
states.forEach((state, clientId) => renderCursor(clientId, state.user));
});
Awareness is ephemeral — it doesn’t persist to the CRDT document, which is exactly what you want for cursors and presence.
Testing and Debugging Your P2P Platform
P2P bugs are notoriously hard to reproduce. Your toolkit:
- Playwright multi-context tests — launch 3 browser contexts, connect them to the same room, assert convergence.
- Network throttling — Chrome DevTools → Network → “Slow 3G” simulates high-latency peers.
- Offline simulation — toggle
provider.disconnect()/provider.connect()to test offline merge. - Deterministic seeds — Yjs lets you set
doc.clientIDfor reproducible tests.
test('three peers converge on concurrent edits', async ({ browser }) => {
const peers = await Promise.all([1, 2, 3].map(() => browser.newContext()));
// ... connect, edit, assert equality
});
Security and Access Control in a Serverless Setup
Without a central server, you lose easy auth. Compensate with:
- Room passwords —
y-webrtcencrypts signaling payloads with a shared password. - DTLS encryption — every WebRTC data channel is encrypted by default.
- Public-key room IDs — hash the room name with a shared secret so only invited peers can join.
- Content validation — never trust incoming CRDT updates blindly; sanitize comment bodies before rendering.
const roomId = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(`${repoName}:${sharedSecret}`)
);
For stronger identity, layer in a DID (Decentralized Identifier) system like did:key and sign updates.
Deployment and Self-Hosting Considerations
You need a signaling server and a static host. The signaling server is tiny — a Node process running y-webrtc’s server:
npx y-webrtc-signaling --port 4444
Production checklist:
| Component | Recommendation |
|---|---|
| Signaling | Node + y-webrtc-signaling behind nginx (WSS) |
| Static host | Caddy, Cloudflare Pages, or a $5 VPS |
| TURN fallback | Coturn (for restrictive NATs) |
| Persistence | IndexedDB (client) + optional S3 snapshot |
| Monitoring | Prometheus + Grafana for signaling uptime |
flowchart LR
U1[Peer Browser] -->|WSS| N[Nginx]
N --> Sig[y-webrtc-signaling]
U1 <-->|UDP/DTLS| U2[Peer Browser]
U1 -.->|fallback| T[Coturn TURN]
T -.-> U2
For teams under ~10 reviewers, a mesh topology works beautifully. Beyond that, add a relay peer that acts as a hub.
Conclusion: The Future of Collaborative Development
A self-hosted, CRDT-backed, WebRTC-powered code review platform gives your team something commercial tools can’t easily match: sovereignty over data, offline-first editing, and near-zero infrastructure cost. The architecture is surprisingly small — a few hundred lines of TypeScript and a $5 VPS.
The trade-offs are real: mesh topologies don’t scale to hundreds of peers, and you own the security story. But for focused teams, internal reviews, or air-gapped environments, P2P review is a compelling alternative to the GitHub monolith. Start with the Yjs + y-webrtc stack, iterate on the UI, and you’ll have a working prototype in an afternoon.
FAQ
What are CRDTs and why are they essential for a serverless code review tool? CRDTs (Conflict-free Replicated Data Types) are data structures that allow multiple replicas to be updated independently and merged automatically without conflicts. In a serverless code review tool, they ensure all peers eventually see the same state even with concurrent edits, eliminating the need for a central server to resolve conflicts.
How does WebRTC enable peer-to-peer communication without a central server? WebRTC establishes direct connections between browsers using a signaling process (which can be minimal), then uses ICE, STUN, and TURN servers to traverse NATs and firewalls. Once connected, data flows directly between peers, reducing latency and server costs. For fully serverless setups, use a lightweight signaling server or even manual signaling.
Can I use this platform for large teams? Yes, but WebRTC mesh networks become inefficient beyond 5–10 peers due to the number of connections. For larger teams, consider a hybrid approach with a central relay or a selective forwarding unit (SFU) for media — though for text-based collaboration, a mesh may suffice for moderate team sizes.
What are the security implications of a peer-to-peer code review platform? Security is critical: use end-to-end encryption (WebRTC’s DTLS is on by default) and authenticate peers through public key cryptography. Implement access control by sharing room IDs or using a decentralized identity system. Always validate incoming data to prevent injection attacks.
How do I handle offline editing and later synchronization? CRDTs are designed for offline-first scenarios. Each peer can edit locally, and when connectivity is restored, changes merge automatically. Ensure your CRDT implementation supports state-based or operation-based merging, and persist local changes in IndexedDB or similar storage.
What libraries are recommended for CRDTs and WebRTC? For CRDTs, popular choices include Yjs, Automerge, and SyncedStore. For WebRTC, use the native WebRTC API or libraries like PeerJS or SimpleWebRTC. Yjs integrates well with WebRTC via y-webrtc for seamless synchronization.
How do I test a peer-to-peer application? Use browser developer tools to simulate multiple peers, or run multiple browser instances. For automated testing, tools like Playwright or Puppeteer can simulate multiple clients. Network condition simulation (e.g., throttling) helps test resilience.