All posts
5 Sep 2026

Building a Collaborative Code Editor: Integrating Monaco and WebSockets in Node.js

A practical architectural guide on embedding the Monaco Editor, capturing keystrokes, and streaming them over WebSockets in Node.js with real-time conflict handling.

Building a Collaborative Code Editor: Integrating Monaco and WebSockets in Node.js

Real-time collaboration has transformed from a “nice-to-have” feature into an absolute baseline expectation for modern developer tools. Think of Google Docs, Figma, or VS Code Live Share—multiple cursors gliding across the screen, text appearing instantaneously as a remote peer types, and seamless synchronization.

Under the hood, building this functionality requires orchestrating three major pillars:

  1. The Client Editor: Embedding and interfacing with a robust code editor (we will use Monaco Editor, the powerhouse driving VS Code).
  2. The Transport Layer: Maintaining low-latency, bi-directional communication channels using WebSockets.
  3. Conflict Resolution: Managing concurrent edits so that users don’t overwrite each other’s work, implemented here via a practical Operational Transformation (OT) approach.

In this architectural guide, we will build a functional, multi-user collaborative code editor from scratch using Node.js, WebSockets, and Monaco.


System Architecture Overview

Before writing code, let’s map out how data flows through our system. Our architecture consists of a static client (HTML/JS running Monaco) and a stateful Node.js WebSocket server.

code
+------------------+         WebSocket         +--------------------+         WebSocket         +------------------+
|  Client A (User) | <-------------------------> |                    | <-------------------------> |  Client B (User) |
|  Monaco Instance |     JSON Delta Stream       |  Node.js WebSocket |     JSON Delta Stream       |  Monaco Instance |
+------------------+                             |       Server       |                             +------------------+
                                                 |  (In-Memory State  |
                                                 |   & OT Dispatcher) |
                                                 +--------------------+

When User A types a character:

  1. Monaco fires a onDidChangeContent event containing precise content changes (content changes as absolute ranges and text).
  2. The client packages this delta and transmits it over the WebSocket connection.
  3. The Node.js server receives the delta, updates its authoritative in-memory document state, assigns a logical sequence revision, and broadcasts the operation to all other connected clients.
  4. Remote clients receive the operation, transform it against any local unacknowledged changes if necessary, and apply it to their local Monaco instance.

Step 1: Setting up the Node.js WebSocket Server

Let’s start by building the backend. We’ll use Node.js with the popular ws library for handling raw WebSockets.

Initialize your project and install dependencies:

mkdir collaborative-editor
cd collaborative-editor
npm init -y
npm install ws uuid

Now, create server.js to manage client connections, room broadcasting, and basic state synchronization:

const WebSocket = require('ws');
const { v4: uuidv4 } = require('uuid');

const wss = new WebSocket.Server({ port: 8080 });

// In-memory state for our document
let documentState = "// Start coding collaboratively here...\n";
let documentVersion = 0;

// Track active clients
const clients = new Map();

wss.on('connection', (ws) => {
    const clientId = uuidv4();
    clients.set(ws, { id: clientId, cursor: null });
    console.log(`Client connected: ${clientId}`);

    // Send initial document state and version to the newly connected client
    ws.send(JSON.stringify({
        type: 'INIT',
        content: documentState,
        version: documentVersion,
        clientId: clientId
    }));

    // Broadcast updated user list
    broadcastPeerList();

    ws.on('message', (message) => {
        try {
            const data = JSON.parse(message);

            switch (data.type) {
                case 'OPERATION':
                    // Simple operational transformation/state update check
                    if (data.version === documentVersion) {
                        documentVersion++;
                        documentState = applyOperationLocally(documentState, data.operation);

                        // Broadcast to all OTHER clients
                        broadcastToOthers(ws, {
                            type: 'OPERATION',
                            operation: data.operation,
                            version: documentVersion,
                            clientId: clientId
                        });
                    } else {
                        // Version mismatch: Send reject or trigger client-side re-sync
                        ws.send(JSON.stringify({
                            type: 'SYNC_ERROR',
                            currentVersion: documentVersion,
                            content: documentState
                        }));
                    }
                    break;

                case 'CURSOR_MOVE':
                    const client = clients.get(ws);
                    if (client) {
                        client.cursor = data.cursor;
                        broadcastToOthers(ws, {
                            type: 'CURSOR_MOVE',
                            clientId: clientId,
                            cursor: data.cursor
                        });
                    }
                    break;
            }
        } catch (err) {
            console.error('Failed to process message:', err);
        }
    });

    ws.on('close', () => {
        console.log(`Client disconnected: ${clientId}`);
        clients.delete(ws);
        broadcastPeerList();
    });
});

function applyOperationLocally(state, op) {
    // A naive application of Monaco's content change range on the string
    const { rangeOffset, rangeLength, text } = op;
    return state.substring(0, rangeOffset) + text + state.substring(rangeOffset + rangeLength);
}

function broadcastToOthers(senderWs, data) {
    const payload = JSON.stringify(data);
    wss.clients.forEach((client) => {
        if (client !== senderWs && client.readyState === WebSocket.OPEN) {
            client.send(payload);
        }
    });
}

function broadcastPeerList() {
    const peerList = [];
    clients.forEach((client) => {
        peerList.push({ id: client.id, cursor: client.cursor });
    });
    const payload = JSON.stringify({ type: 'PEERS', peers: peerList });
    wss.clients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
            client.send(payload);
        }
    });
}

console.log('WebSocket server running on ws://localhost:8080');

Step 2: Setting up the Client with Monaco Editor

Next, let’s create our front-end interface. We will load Monaco via a CDN for simplicity, initialize an editor instance, and wire up event listeners.

Create public/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Real-Time Collaborative Code Editor</title>
    <style>
        body { font-family: sans-serif; margin: 0; display: flex; height: 100vh; overflow: hidden; }
        #sidebar { width: 250px; background: #1e1e1e; color: #fff; padding: 15px; box-sizing: border-box; }
        #editor-container { flex-grow: 1; height: 100%; }
        h3 { margin-top: 0; font-size: 1rem; border-bottom: 1px solid #444; padding-bottom: 8px; }
        ul { padding-left: 20px; font-size: 0.9rem; color: #aaa; }
    </style>
</head>
<body>
    <div id="sidebar">
        <h3>Active Peers</h3>
        <ul id="peer-list"></ul>
    </div>
    <div id="editor-container"></div>

    <!-- Load Monaco Editor via AMD loader -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
    <script src="app.js"></script>
</body>
</html>

Step 3: Handling Keystrokes & Applying Deltas (app.js)

Now, let’s write the core client-side logic in public/app.js. This script will:

  1. Initialize Monaco.
  2. Open the WebSocket connection.
  3. Prevent infinite loops by flagging local vs. remote edits (isRemoteUpdate).
  4. Track and transmit cursor and selection changes.

Create public/app.js:

require.config({ paths: { 'vs': 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min' }});

require(['vs/editor/editor.main'], function() {
    let editor = null;
    let ws = null;
    let documentVersion = 0;
    let myClientId = null;
    let isRemoteUpdate = false;

    // Initialize Monaco Editor
    editor = monaco.editor.create(document.getElementById('editor-container'), {
        value: '// Connecting to collaboration server...',
        language: 'javascript',
        theme: 'vs-dark',
        automaticLayout: true
    });

    // Connect via WebSocket
    ws = new WebSocket('ws://localhost:8080');

    ws.onopen = () => {
        console.log('Connected to WebSocket server');
    };

    ws.onmessage = (event) => {
        const data = JSON.parse(event.data);

        switch (data.type) {
            case 'INIT':
                myClientId = data.clientId;
                documentVersion = data.version;
                isRemoteUpdate = true;
                editor.setValue(data.content);
                isRemoteUpdate = false;
                break;

            case 'OPERATION':
                documentVersion = data.version;
                isRemoteUpdate = true;
                
                // Apply remote change to Monaco
                editor.executeEdits('remote', [{
                    range: new monaco.Range(
                        data.operation.range.startLineNumber,
                        data.operation.range.startColumn,
                        data.operation.range.endLineNumber,
                        data.operation.range.endColumn
                    ),
                    text: data.operation.text,
                    forceMoveMarkers: true
                }]);
                
                isRemoteUpdate = false;
                break;

            case 'SYNC_ERROR':
                console.warn('State desync detected. Re-syncing...');
                documentVersion = data.currentVersion;
                isRemoteUpdate = true;
                editor.setValue(data.content);
                isRemoteUpdate = false;
                break;

            case 'PEERS':
                updatePeerUI(data.peers);
                break;
        }
    };

    // Capture local typing events
    editor.onDidChangeModelContent((e) => {
        if (isRemoteUpdate) return; // Ignore events triggered by remote syncs

        // Send each change delta to the server
        e.changes.forEach((change) => {
            const operation = {
                range: change.range,
                rangeOffset: change.rangeOffset,
                rangeLength: change.rangeLength,
                text: change.text
            };

            ws.send(JSON.stringify({
                type: 'OPERATION',
                version: documentVersion,
                operation: operation
            }));
        });
    });

    // Capture cursor movements
    editor.onDidChangeCursorPosition((e) => {
        if (isRemoteUpdate) return;
        
        ws.send(JSON.stringify({
            type: 'CURSOR_MOVE',
            cursor: {
                lineNumber: e.position.lineNumber,
                column: e.position.column
            }
        }));
    });

    function updatePeerUI(peers) {
        const peerListEl = document.getElementById('peer-list');
        peerListEl.innerHTML = '';
        peers.forEach(peer => {
            const li = document.createElement('li');
            const isMe = peer.id === myClientId;
            li.textContent = `Peer: ${peer.id.substring(0, 6)}... ${isMe ? '(You)' : ''}`;
            if (peer.cursor) {
                li.textContent += ` [Ln ${peer.cursor.lineNumber}, Col ${peer.cursor.column}]`;
            }
            peerListEl.appendChild(li);
        });
    }
});

To serve static assets on the client side easily, you can use a quick HTTP server like http-server or integrate Express into your Node backend.

npm install -g http-server
http-server public -p 3000

Now, open http://localhost:3000 in multiple browser windows and watch your keystrokes synchronize in real time!


Step 4: Real-World Conflict Handling and Operational Transformation (OT)

In our simplified example above, we used an optimistic concurrency control mechanism: tracking a documentVersion counter. If two users type simultaneously at different versions, the server rejects the out-of-date message and forces a hard re-sync (SYNC_ERROR).

While functional for demo apps, this approach breaks down under heavy concurrent typing because overwriting local state destroys user input.

Moving Toward Production-Ready OT

True Operational Transformation (OT) algorithms (like those implemented in Google Wave or ShareJS) do not reject concurrent operations. Instead, they transform incoming operations against concurrent local operations that have not yet been acknowledged by the server.

Client A State          Server State          Client B State
[Edit 1] ------------> [Accepts v1] ---------> Receives [Edit 1]
                           |
                           v
                       Transforms
                     against pending
                      [Local Edit]

Key Concepts for Production OT Implementations:

  1. Transformation Functions ($Transform(op1, op2)$): If Client A inserts text at index 5, and Client B inserts text at index 3 at the same time, when Client B’s operation arrives at Client A, its index must be shifted right by the length of Client A’s insertion.
  2. State Vectors: Tracking individual user sequence acknowledgements rather than a monolithic global integer counter prevents head-of-line blocking in larger multi-user rooms.
  3. Production Libraries: Writing a bulletproof OT engine from scratch is notoriously complex due to edge cases around overlapping ranges, deletions, and multi-line formatting. For enterprise-grade production applications, rely heavily on battle-tested libraries designed specifically to wrap Monaco:
    • Yjs (with y-monaco bindings)
    • Automerge (Conflict-free Replicated Data Types - CRDTs)

Best Practices and Production Considerations

When transitioning your prototype to a production cluster, keep these engineering considerations in mind:

  • Horizontal Scaling & Redis Pub/Sub: A single Node.js process handles WebSockets well, but horizontal scaling requires multiple backend instances. Use Redis Pub/Sub or a sticky-session load balancer (like NGINX) to broadcast WebSocket messages across distinct cluster nodes.
  • Heartbeat & Reconnection Logic: WebSockets can drop silently due to corporate proxies, sleep states, or flaky Wi-Fi. Implement an application-level ping/pong heartbeat mechanism and exponential backoff reconnection routines on the client.
  • Debounced Cursor Streaming: Broadcasting every micro-movement of a user’s cursor can flood your WebSocket server. Throttle or debounce onDidChangeCursorPosition events to send updates at most every 50–100ms.
  • Authorization & Room Management: Never allow arbitrary global document editing. Implement JSON Web Token (JWT) verification during the WebSocket handshake stage to ensure users only join authorized document rooms.

Conclusion

By pairing Monaco Editor’s granular change events with a Node.js WebSocket backend, you can build responsive, highly engaging real-time collaborative applications. While building a naive version using revision counters provides a great conceptual foundation, adopting robust synchronization libraries like Yjs or Automerge will ensure your production app handles concurrent edits smoothly and reliably.

More posts