Skip to content

How Do You Build a Real-Time Collaborative Code Editor with Y.js and CodeMirror?

Problem

I wanted to build a real-time collaborative code editor—think Google Docs, but for writing code. Multiple users should be able to edit the same file simultaneously, see each other’s cursors, and never run into “this file is locked” messages.

Traditional collaboration approaches like operational transforms (OT) require a central server to coordinate every change. This creates bottlenecks, single points of failure, and complex conflict resolution logic. I wanted something simpler that could even work offline.

Then I discovered Y.js and its CRDT-based approach to collaboration. Here’s what I learned building a collaborative editor.

Environment

I built this with the following stack:

Development Environment
Node.js: 20.x
Y.js: 13.6.x
y-websocket: 2.x
CodeMirror: 6.x (or 5.x with y-codemirror)
React: 18.x

What Happened When I Tried Traditional Approaches

First, I tried the traditional lock-based approach:

Traditional Lock-Based Collaboration
User A opens file → Server locks file → User B sees "File locked by User A"
User A finishes → Server releases lock → User B can now edit

This is painful. Users get frustrated when they can’t edit. The server becomes a bottleneck. And if the server goes down, collaboration stops entirely.

Then I looked at operational transforms (OT). Google Docs uses OT, so it should work, right?

The problem: OT requires the server to resolve every conflict. Every edit must be acknowledged by the server before it’s considered “real.” This means:

OT Latency Chain
Edit → Send to server → Server transforms → Broadcast to others → Ack back
Total: Round-trip latency for every single character typed

For a code editor where developers type fast, this creates noticeable lag. Plus, the server logic for OT is notoriously complex.

How to Solve It: Y.js + CodeMirror

Y.js uses Conflict-free Replicated Data Types (CRDTs) instead of OT. The key insight: each client can resolve conflicts locally without server coordination.

Here’s how I built it:

Step 1: Basic Setup

basic-setup.js
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { EditorView, basicSetup } from 'codemirror'
import { javascript } from '@codemirror/lang-javascript'
// Create a Y.js document
const ydoc = new Y.Doc()
// Connect to WebSocket server for sync
const provider = new WebsocketProvider(
'wss://your-websocket-server.com',
'my-room-id',
ydoc
)
// Get the shared text type
const ytext = ydoc.getText('codemirror')
// Create CodeMirror instance
const view = new EditorView({
doc: '',
extensions: [basicSetup, javascript()],
parent: document.getElementById('editor')
})

Step 2: Binding Y.js to CodeMirror

I initially made a mistake here. I tried to manually sync the Y.js document with CodeMirror:

wrong-manual-sync.js
// WRONG: Manual sync approach
ytext.observe(event => {
// This gets complicated fast with:
// - Cursor position tracking
// - Selection preservation
// - Undo/redo handling
// - Conflict resolution
})

This quickly became a nightmare of edge cases. The correct approach is to use the y-codemirror binding:

correct-binding.js
import * as Y from 'yjs'
import { CodemirrorBinding } from 'y-codemirror'
import { WebsocketProvider } from 'y-websocket'
import CodeMirror from 'codemirror'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider(
'wss://your-websocket-server.com',
'room-id',
ydoc
)
const ytext = ydoc.getText('codemirror')
const editor = CodeMirror(document.getElementById('editor'), {
mode: 'python',
lineNumbers: true
})
// This binding handles everything:
// - Sync
// - Cursor tracking
// - Undo/redo
// - Presence (seeing other users)
const binding = new CodemirrorBinding(
ytext,
editor,
provider.awareness
)

Step 3: Adding User Presence (Cursors and Selections)

Seeing where other users are editing is crucial for collaboration. Y.js calls this “awareness”:

awareness.js
// Set local awareness state
provider.awareness.setLocalStateField('user', {
name: 'Alice',
color: '#ff6b6b',
clientId: provider.awareness.clientID
})
// Listen for other users' awareness changes
provider.awareness.on('change', () => {
const states = provider.awareness.getStates()
states.forEach((state, clientId) => {
if (clientId !== provider.awareness.clientID) {
console.log(`${state.user.name} is at cursor position ${state.cursor}`)
}
})
})

Step 4: React Component with Full Implementation

Here’s a complete React component that I ended up with:

CollaborativeEditor.jsx
import React, { useEffect, useRef, useState } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { yCollab } from 'y-codemirror.next'
import CodeMirror from '@uiw/react-codemirror'
function CollaborativeEditor({ roomId, username }) {
const editorRef = useRef(null)
const [provider, setProvider] = useState(null)
const [connectedUsers, setConnectedUsers] = useState([])
useEffect(() => {
const ydoc = new Y.Doc()
const wsProvider = new WebsocketProvider(
'wss://sync.example.com',
roomId,
ydoc
)
// Set local awareness
wsProvider.awareness.setLocalStateField('user', {
name: username,
color: '#' + Math.floor(Math.random() * 16777215).toString(16)
})
// Track connected users
wsProvider.awareness.on('change', () => {
const users = Array.from(wsProvider.awareness.getStates().values())
.map(state => state.user)
.filter(Boolean)
setConnectedUsers(users)
})
setProvider(wsProvider)
return () => {
wsProvider.destroy()
ydoc.destroy()
}
}, [roomId, username])
return (
<div className="collaborative-editor">
<div className="connected-users">
{connectedUsers.map((user, i) => (
<span
key={i}
style={{ backgroundColor: user.color }}
className="user-badge"
>
{user.name}
</span>
))}
</div>
<div ref={editorRef} className="editor" />
</div>
)
}
export default CollaborativeEditor

Step 5: Minimal WebSocket Server

The server side is surprisingly simple. Y.js handles all the conflict resolution; the server just needs to broadcast messages:

server.js
import { WebSocketServer } from 'ws'
import { setupWSConnection } from 'y-websocket/bin/utils'
const wss = new WebSocketServer({ port: 1234 })
wss.on('connection', (ws, req) => {
// Extract room ID from URL path
const roomId = new URL(req.url, 'http://localhost').pathname.slice(1)
setupWSConnection(ws, req, { docName: roomId })
})
console.log('WebSocket server running on port 1234')

That’s it. No conflict resolution logic on the server. No locks. No “file is being edited” messages.

The Reason: Why CRDT Works

The magic is in Conflict-free Replicated Data Types. Here’s what makes CRDTs special:

Mathematical Guarantee of Convergence

Every Y.js document update includes a unique identifier (client ID + sequence number). When two users edit the same position, Y.js deterministically orders the changes:

CRDT Convergence Example
Time | User A | User B
-----|-------------------|------------------
T1 | Types "H" |
T2 | Types "e" | Types "W"
T3 | Types "l" | Types "o"
T4 | | Types "r"
Result after sync: Both see "HelWor" (ordered by client ID)

Both clients independently reach the same state without server coordination.

Offline-First Design

Offline Sync Flow
Online: Edit → Broadcast immediately → All clients update
Offline: Edit → Queue locally → Reconnect → Sync all changes

Y.js maintains a local state that syncs when connectivity returns. No data loss, no manual merge required.

No Central Server Logic

Architecture Comparison
Traditional OT:
Client → Server (transforms) → Server (resolves) → Clients
CRDT (Y.js):
Client → Server (just forwards) → Clients
No transformation logic needed

The server becomes a dumb message forwarder. You can use any WebSocket server, or even serverless options like:

serverless-sync.js
// Using y-indexeddb for persistence
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
const persistence = new IndexeddbPersistence('my-document', ydoc)
// Using y-webrtc for peer-to-peer (no server at all)
import { WebrtcProvider } from 'y-webrtc'
const provider = new WebrtcProvider('my-room', ydoc)

Memory Overhead (The Trade-off)

CRDTs aren’t free. Y.js stores metadata for every character:

Memory Overhead
Plain text: 1 byte per character
Y.js text: ~5-10 bytes per character (depending on operations)
Solution: Use Y.js garbage collection or Y.Doc.gc = true

For code files (typically under 10,000 lines), this overhead is negligible. For massive documents, consider periodic compaction.

Common Mistakes I Made

Mistake 1: Not cleaning up providers

memory-leak-wrong.js
// WRONG: Provider never cleaned up
useEffect(() => {
const provider = new WebsocketProvider(...)
// Missing cleanup = memory leak
}, [])

The fix:

memory-leak-correct.js
// CORRECT: Clean up on unmount
useEffect(() => {
const provider = new WebsocketProvider(...)
return () => {
provider.destroy()
ydoc.destroy()
}
}, [])

Mistake 2: Connecting multiple providers to the same document

duplicate-providers-wrong.js
// WRONG: Multiple providers on same Y.Doc
const provider1 = new WebsocketProvider(url1, room, ydoc)
const provider2 = new WebsocketProvider(url2, room, ydoc)
// This causes sync conflicts

Use one provider per Y.Doc, or use separate Y.Docs.

Mistake 3: Ignoring awareness state cleanup

When a user disconnects, their awareness state should be removed:

awareness-cleanup.js
wsProvider.on('sync', (isSynced) => {
if (isSynced) {
// Awareness is now synced with room
}
})
wsProvider.on('connection-close', () => {
// Awareness automatically cleaned up by library
})

Mistake 4: Not throttling for large documents

For files with 10,000+ lines, sync every keystroke creates performance issues:

throttle-sync.js
import { throttle } from 'lodash'
const throttledSync = throttle(() => {
// Batch updates
}, 100) // Sync at most every 100ms

Mistake 5: Using CodeMirror 5 bindings with CodeMirror 6

The y-codemirror package is for CodeMirror 5. For CodeMirror 6, use:

codemirror6-correct.js
import { yCollab } from 'y-codemirror.next'
import { EditorView } from '@codemirror/view'
const ytext = ydoc.getText('document')
const extension = yCollab(ytext, provider.awareness)
const view = new EditorView({
extensions: [basicSetup, extension]
})

Summary

In this post, I showed how to build a real-time collaborative code editor using Y.js and CodeMirror:

  • CRDT eliminates server-side conflict resolution: Each client resolves conflicts mathematically, guaranteed to converge
  • Offline-first works out of the box: Changes queue locally and sync when connected
  • Server is minimal: Just a WebSocket message forwarder, no application logic needed
  • Presence awareness is built-in: See other users’ cursors and selections automatically

The key insight from the Reddit discussion about PyTogether: Y.js + CodeMirror is exactly how modern collaborative editors work. No locks, no central coordination, just mathematically guaranteed convergence.

If you’re building collaborative editing features, start with Y.js. The learning curve is gentler than OT, and the offline support is a bonus you get for free.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments