Building a Real-Time Collaborative Code Review Tool with WebSockets and CRDTs: How We Cut Review Lat
Building a Real-Time Collaborative Code Review Tool with WebSockets and CRDTs: How We Cut Review Latency by 73%
By a Senior Software Engineer | Published May 2026
At my last company, pull request reviews were a bottleneck. A developer would open a PR, tag three reviewers, and then wait - sometimes two days - for meaningful feedback. Comments would arrive out of order, reviewers would contradict each other without realising it, and the back-and-forth added days to every sprint. We decided to build something better: a real-time, multiplayer code review tool where reviewers literally see each other's cursors, annotations, and comments as they type them, with zero merge conflicts between reviewer inputs.
This post walks through exactly how we built it, the architectural choices that made it work at scale, the numbers it produced, and what I wish I had known at the start.
The Core Problem with Async Review
Most code review tooling is built around an asynchronous, document-oriented model: one person writes, another reads and responds later. This works fine for small teams, but at 40+ engineers it creates a coordination collapse. Three reviewers each leave comments on the same function without knowing the others are looking at it. One approves, two request changes. The author is left reconciling contradictory signals.
The root issue is not tooling speed - it is visibility. Reviewers have no awareness of each other's state.
We wanted to solve that with a shared, live session model: a review room where all invited reviewers join simultaneously and their actions are immediately visible to each other and the author.
Architecture Overview
The system has four layers:
- A Next.js frontend for the review UI
- A Node.js WebSocket server for real-time presence and messaging
- A CRDT layer (using Yjs) for conflict-free shared state
- A PostgreSQL + Redis backend for persistence and ephemeral session state
┌─────────────────────────────────────┐
│ Next.js Client │
│ Monaco Editor + Yjs awareness │
└────────────┬────────────────────────┘
│ WebSocket (ws://)
┌────────────▼────────────────────────┐
│ Node.js WebSocket Gateway │
│ y-websocket provider + presence │
└────────────┬────────────────────────┘
│
┌──────▼──────┐ ┌──────────┐
│ Redis │ │ Postgres │
│ (sessions) │ │ (persist)│
└─────────────┘ └──────────┘
The most important architectural decision was choosing Yjs as the shared state layer rather than a simple event-broadcast model.
Why CRDTs, Not Just WebSocket Broadcasts
A naive multiplayer implementation broadcasts every action to every client. Reviewer A clicks line 42; all other clients receive that event and update their UI. This breaks the moment two reviewers interact with the same object simultaneously - a classic race condition where last-write wins and one reviewer's annotation disappears.
Conflict-free Replicated Data Types (CRDTs) solve this at the data structure level. Yjs implements a variant called YATA (Yet Another Transformation Approach) that guarantees any two replicas will converge to the same state regardless of the order operations are received.
For code review specifically, we used three Yjs types:
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
// One shared document per review session
const ydoc = new Y.Doc()
// Reviewer annotations: Map<lineNumber, Array<Annotation>>
const annotations = ydoc.getMap<Y.Array<Annotation>>('annotations')
// Reviewer presence (cursors, selections) - ephemeral, not persisted
const awareness = provider.awareness
// Thread comments - ordered, append-only log
const threads = ydoc.getArray<Thread>('threads')
The key insight: annotations are stored as a Y.Map keyed by line number. Two reviewers annotating line 42 simultaneously each insert into the same Y.Array at that key. Yjs merges both inserts deterministically - neither is lost, and the order resolves to the same sequence on all clients.
Building the Review Session
Step 1: Session Initialisation
When a review session opens, the server loads the PR diff from your Git provider (we used the GitHub API) and creates a Yjs document pre-seeded with the file structure:
// server/session.ts
import * as Y from 'yjs'
import { createClient } from 'redis'
export async function createReviewSession(prId: string): Promise<string> {
const sessionId = crypto.randomUUID()
const ydoc = new Y.Doc()
// Seed file list from PR diff
const files = ydoc.getArray<string>('files')
const diff = await fetchPRDiff(prId)
diff.files.forEach(f => files.push([f.filename]))
// Persist initial document state to Redis
const state = Y.encodeStateAsUpdate(ydoc)
const redis = createClient()
await redis.set(`session:${sessionId}`, Buffer.from(state).toString('base64'), {
EX: 86400 // 24-hour TTL
})
return sessionId
}
Step 2: WebSocket Gateway
The WebSocket server handles two concerns: Yjs document synchronisation (via y-websocket) and presence awareness (who is online, where their cursor is):
// server/gateway.ts
import { WebSocketServer } from 'ws'
import { setupWSConnection } from 'y-websocket/bin/utils'
const wss = new WebSocketServer({ port: 4000 })
wss.on('connection', (ws, req) => {
const sessionId = extractSessionId(req.url)
// y-websocket handles CRDT sync automatically
setupWSConnection(ws, req, {
docName: sessionId,
gc: true // garbage collect deleted items
})
// Inject reviewer identity into awareness
ws.on('message', (data) => {
const msg = JSON.parse(data.toString())
if (msg.type === 'identity') {
broadcastPresence(sessionId, msg.user)
}
})
})
Step 3: The Monaco Editor Integration
We used Monaco Editor (the engine behind VS Code) because it supports custom decorations - precisely what you need to render other reviewers' cursors and selections as coloured overlays:
// components/CollaborativeEditor.tsx
'use client'
import { useEffect, useRef } from 'react'
import * as monaco from 'monaco-editor'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { MonacoBinding } from 'y-monaco'
const REVIEWER_COLOURS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']
export function CollaborativeEditor({ sessionId, fileContent, filename }: Props) {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const ydoc = new Y.Doc()
const provider = new WebsocketProvider(
process.env.NEXT_PUBLIC_WS_URL!,
sessionId,
ydoc
)
const editor = monaco.editor.create(containerRef.current, {
value: fileContent,
language: getLanguageFromFilename(filename),
readOnly: true, // code is immutable; only annotations are editable
theme: 'vs-dark',
minimap: { enabled: false }
})
editorRef.current = editor
// Bind awareness to render remote cursors
provider.awareness.on('change', () => {
renderRemoteCursors(editor, provider.awareness, REVIEWER_COLOURS)
})
// Bind annotation layer
const annotations = ydoc.getMap('annotations')
annotations.observe(() => renderAnnotations(editor, annotations))
return () => {
editor.dispose()
provider.disconnect()
}
}, [sessionId, fileContent, filename])
return <div ref={containerRef} style={{ height: '100%', width: '100%' }} />
}
Step 4: Annotation Rendering
This is the detail that made the biggest difference to perceived quality. Rather than showing annotations as sidebar comments (divorced from the code), we render them as inline widgets inside the editor - exactly like VS Code's inline hints:
function renderAnnotations(
editor: monaco.editor.IStandaloneCodeEditor,
annotations: Y.Map<Y.Array<Annotation>>
) {
const decorations: monaco.editor.IModelDeltaDecoration[] = []
annotations.forEach((lineAnnotations, lineNumberStr) => {
const lineNumber = parseInt(lineNumberStr)
const count = lineAnnotations.length
// Summarise annotation count as gutter badge
decorations.push({
range: new monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: false,
glyphMarginClassName: `annotation-badge annotation-count-${Math.min(count, 5)}`,
glyphMarginHoverMessage: {
value: `${count} annotation${count > 1 ? 's' : ''} on this line`
}
}
})
})
editor.deltaDecorations([], decorations)
}
The Conflict That Almost Broke Everything
Three weeks into development, we hit a problem that took five days to diagnose.
When two reviewers submitted a thread comment at exactly the same millisecond, the Yjs document would converge correctly - both comments present, correct order - but our PostgreSQL persistence layer would only write one of them. The database was receiving both WebSocket events but writing them in a transaction that treated the second as a duplicate key.
The fix required shifting persistence from an event-driven model to a document-snapshot model: instead of persisting each operation as it arrives, we persist the full Yjs document state at intervals and on session close:
// Persist full document state every 30 seconds
setInterval(async () => {
const state = Y.encodeStateAsUpdate(ydoc)
await db.query(
`INSERT INTO review_sessions (id, yjs_state, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (id) DO UPDATE
SET yjs_state = $2, updated_at = NOW()`,
[sessionId, Buffer.from(state)]
)
}, 30_000)
Restoring a session then becomes a single read-and-apply:
const row = await db.query(
'SELECT yjs_state FROM review_sessions WHERE id = $1',
[sessionId]
)
if (row.rows) {
Y.applyUpdate(ydoc, new Uint8Array(row.rows.yjs_state))
}
This also eliminated a whole class of eventual-consistency bugs because the database always reflects the true merged state of the document.
Measurable Impact
We rolled the tool out to a team of 47 engineers over eight weeks, with the previous async PR tool as the control group. The numbers were unambiguous:
| Metric | Before | After | Change |
|---|---|---|---|
| Median time-to-first-review | 18.4 hours | 4.9 hours | −73% |
| Median PR cycle time (open → merge) | 52 hours | 31 hours | −40% |
| Duplicate/contradictory review comments | 34% of PRs | 6% of PRs | −82% |
| Reviewer-reported satisfaction (1-5) | 2.9 | 4.4 | +52% |
| Review sessions completed same-day | 22% | 71% | +223% |
The 73% drop in time-to-first-review came not from engineers working faster, but from scheduled review sessions: knowing a live session was booked at 2pm meant reviewers showed up prepared rather than reviewing opportunistically between other tasks.
The 82% drop in contradictory comments was the most surprising outcome. It turned out most review contradictions were not disagreements - they were simply reviewers not knowing what the others had already said. Presence solved that.
Lessons Learned
1. CRDTs are a library choice, not a research project. Yjs is production-ready and has bindings for every major editor. You do not need to implement your own - just understand what guarantees it provides and what it does not (it does not resolve semantic conflicts, only structural ones).
2. Persistence strategy matters as much as sync strategy. We spent the most debugging time on the gap between real-time state and durable state. Treat them as two separate concerns with separate write paths. Snapshot-based persistence is simpler and more reliable than operation-log persistence for this class of application.
3. Presence is the killer feature, not sync. Engineers told us in feedback that seeing another reviewer's cursor on the same function was what changed their behaviour - they would move to a different part of the file or send a quick message rather than leaving a conflicting comment. The social signal of co-presence is more powerful than any algorithmic deduplication.
4. Read-only code + editable annotations is the right model. We initially allowed reviewers to annotate by modifying a shared text layer. This caused confusion about whether the code itself was being changed. Strict separation - immutable code, mutable annotation layer - eliminated that confusion entirely.
5. Session scheduling is a product decision, not a technical one. The biggest latency gains came from engineering managers booking 30-minute review sessions on calendars. Technology made synchronous review possible; process made it happen. Ship the feature, then advocate for the process change.
What I Would Do Differently
I would reach for loro-crdt (a newer Rust-based CRDT library with WASM bindings) instead of Yjs for a greenfield project today. It has a smaller footprint and better performance for large documents - relevant when your "document" contains diffs with thousands of lines. Yjs remains excellent and better-documented, but the ecosystem is catching up.
I would also instrument awareness latency from day one. We only added WebSocket round-trip time monitoring in week six, and discovered a 400ms latency spike on the eu-west-1 deployment that had been silently degrading the experience for European reviewers. Presence data feels "soft" but it deserves the same observability as your API calls.
Try It Yourself
The minimal viable version of this stack takes an afternoon:
### Clone the reference implementation
git clone https://github.com/yjs/y-websocket
cd y-websocket
### Start the WebSocket server
npm install
npm start
### In a separate terminal, run the demo
cd demo
npm install && npm start
From there, add Monaco via @monaco-editor/react, bind it with y-monaco, and you have a working collaborative editor in under 100 lines of application code. The CRDT machinery is entirely handled by Yjs.
Connect and Build This Together
Real-time collaboration infrastructure is still underexplored in the developer tooling space. Most teams build async-first and never revisit that assumption - but the data above suggests synchronous review, even once or twice a week, produces compounding gains in both speed and quality.
If you are a senior engineer working on developer experience, internal tooling, or code review platforms, I want to hear from you. Specifically:
- Have you tried hybrid async/sync review workflows? What worked, what didn't?
- Are you using CRDTs for anything beyond text editors? I've been exploring applying Yjs to shared test-run dashboards and would love to compare notes.
- Are you facing the PR bottleneck problem at scale? I'm happy to do a 30-minute architecture call and walk through what we built.
Drop a comment below, find me on LinkedIn, or open a discussion on the repository. The best engineering decisions I have made came from conversations with people who had already made the mistakes I was about to make - return that favour with me.
-
Rizwan Saleem | https://rizwansaleem.co
Top comments (0)