DEV Community

Chad Dower
Chad Dower

Posted on

Building a Real-Time Collaborative Text Editor: WebSockets Implementation with CRDT Data Structures

Why Real-Time Collaboration Matters

Real-time collaborative editing has transformed how teams work together. Whether it's writing documents, coding, or designing, the ability to see changes instantly creates a fluid, productive experience that async collaboration can't match.

Key benefits of real-time collaboration:

  • Instant feedback reduces miscommunication and revision cycles
  • Multiple team members can work simultaneously without blocking each other
  • Changes are automatically synced, eliminating manual merge conflicts
  • Remote teams can collaborate as effectively as in-person

But building collaborative features is notoriously difficult. The challenge? When multiple users edit the same text simultaneously, their changes can conflict in complex ways. Traditional approaches using locks or operational transforms are complicated and error-prone. That's where CRDTs come in—they provide a mathematically proven way to resolve conflicts automatically.

Prerequisites

Before we dive in, make sure you have:

  • Solid understanding of JavaScript (ES6+)
  • Basic knowledge of Node.js and Express
  • Familiarity with WebSocket concepts (or willingness to learn)
  • Node.js 16+ installed on your machine

Understanding the Architecture

Before writing code, let's understand how our collaborative editor will work.

The Challenge: Concurrent Editing

Imagine two users editing the word "cat" simultaneously:

  • User A inserts "h" at position 1 to make "chat"
  • User B inserts "r" at position 0 to make "rcat"

If we simply apply these operations in order, we get different results depending on the order:

  • A then B: "rchat"
  • B then A: "crhat"

This is the fundamental problem of distributed systems—operations that arrive in different orders can produce different states.

The Solution: CRDTs

Conflict-free Replicated Data Types (CRDTs) solve this by ensuring operations are commutative—they produce the same result regardless of order. For text editing, we'll use a simplified CRDT approach where each character gets a unique position that remains stable even as text changes around it.

Our architecture will have three main components:

  1. WebSocket Server: Manages connections and broadcasts changes
  2. CRDT Engine: Handles conflict resolution and maintains document consistency
  3. Client Editor: Provides the user interface and local editing

Setting Up the Project

Let's start by creating our project structure and installing dependencies.

mkdir collaborative-editor
cd collaborative-editor
npm init -y
npm install express socket.io uuid
npm install -D nodemon
Enter fullscreen mode Exit fullscreen mode

Create the following folder structure:

collaborative-editor/
├── server/
│   ├── index.js
│   └── crdt.js
├── client/
│   ├── index.html
│   ├── editor.js
│   └── style.css
└── package.json
Enter fullscreen mode Exit fullscreen mode

Update your package.json scripts:

{
  "scripts": {
    "start": "node server/index.js",
    "dev": "nodemon server/index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Building the WebSocket Server

Let's create our WebSocket server that will handle client connections and message broadcasting.

// server/index.js
const express = require('express');
const http = require('http');
const socketIO = require('socket.io');
const path = require('path');
const CRDT = require('./crdt');

const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
  cors: {
    origin: "*",
    methods: ["GET", "POST"]
  }
});

// Serve static files
app.use(express.static(path.join(__dirname, '../client')));

// Store document state
const document = new CRDT();
const connectedUsers = new Map();

io.on('connection', (socket) => {
  console.log('User connected:', socket.id);

  // Send current document state to new user
  socket.emit('document-state', {
    content: document.getText(),
    version: document.version
  });

  // Handle text operations
  socket.on('operation', (op) => {
    try {
      // Apply operation to CRDT
      document.applyOperation(op);

      // Broadcast to all other clients
      socket.broadcast.emit('remote-operation', op);
    } catch (error) {
      console.error('Invalid operation:', error);
      socket.emit('error', { message: 'Invalid operation' });
    }
  });

  // Handle cursor position updates
  socket.on('cursor-position', (position) => {
    connectedUsers.set(socket.id, {
      cursor: position,
      color: getRandomColor()
    });

    socket.broadcast.emit('cursor-update', {
      userId: socket.id,
      position: position
    });
  });

  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
    connectedUsers.delete(socket.id);
    socket.broadcast.emit('user-left', socket.id);
  });
});

function getRandomColor() {
  const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
  return colors[Math.floor(Math.random() * colors.length)];
}

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Using Socket.IO instead of raw WebSockets gives us automatic reconnection, fallback mechanisms for older browsers, and a cleaner event-based API.

Implementing the CRDT Algorithm

Now for the interesting part—implementing a CRDT that can handle concurrent edits. We'll use a simplified approach based on unique character IDs.

// server/crdt.js
const { v4: uuidv4 } = require('uuid');

class CRDT {
  constructor() {
    this.characters = [];
    this.version = 0;
  }

  // Generate unique position between two positions
  generatePositionBetween(before, after) {
    // Simple fractional indexing
    const beforePos = before ? before.position : 0;
    const afterPos = after ? after.position : 1;

    return {
      position: (beforePos + afterPos) / 2,
      id: uuidv4(),
      timestamp: Date.now()
    };
  }

  // Insert character at index
  insert(index, char, userId) {
    const before = this.characters[index - 1] || null;
    const after = this.characters[index] || null;

    const position = this.generatePositionBetween(before, after);

    const charObj = {
      ...position,
      char: char,
      userId: userId
    };

    // Insert maintaining position order
    let insertIndex = this.characters.findIndex(c => c.position > position.position);
    if (insertIndex === -1) insertIndex = this.characters.length;

    this.characters.splice(insertIndex, 0, charObj);
    this.version++;

    return {
      type: 'insert',
      character: charObj
    };
  }

  // Delete character by ID
  delete(charId) {
    const index = this.characters.findIndex(c => c.id === charId);
    if (index !== -1) {
      this.characters.splice(index, 1);
      this.version++;
      return {
        type: 'delete',
        charId: charId
      };
    }
    return null;
  }

  // Apply remote operation
  applyOperation(op) {
    switch(op.type) {
      case 'insert':
        // Check if we already have this character (dedup)
        if (!this.characters.find(c => c.id === op.character.id)) {
          let insertIndex = this.characters.findIndex(
            c => c.position > op.character.position
          );
          if (insertIndex === -1) insertIndex = this.characters.length;

          this.characters.splice(insertIndex, 0, op.character);
          this.version++;
        }
        break;

      case 'delete':
        this.delete(op.charId);
        break;
    }
  }

  // Get plain text representation
  getText() {
    return this.characters.map(c => c.char).join('');
  }
}

module.exports = CRDT;
Enter fullscreen mode Exit fullscreen mode

Note: This CRDT implementation uses fractional indexing—each character gets a decimal position between 0 and 1. When inserting between two characters, we calculate the average of their positions. This ensures stable ordering even with concurrent inserts.

Creating the Client Editor

Now let's build the client-side editor with a clean, functional interface.

<!-- client/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Collaborative Editor</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Collaborative Text Editor</h1>
            <div class="status">
                <span id="connection-status" class="status-indicator">
                    Connecting...
                </span>
                <span id="user-count">1 user</span>
            </div>
        </header>

        <div class="editor-container">
            <div id="editor" contenteditable="true" 
                 placeholder="Start typing..."></div>
            <div id="cursors"></div>
        </div>

        <div class="info">
            <p>Share this URL with others to collaborate!</p>
        </div>
    </div>

    <script src="/socket.io/socket.io.js"></script>
    <script src="editor.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode
/* client/style.css */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
}

.container {
    background: white;
    border-radius: 12px;
    box-shadow: 0 20px 60px rgba(0,0,0,0.3);
    width: 90%;
    max-width: 800px;
    overflow: hidden;
}

header {
    background: #f7f9fc;
    padding: 20px 30px;
    border-bottom: 1px solid #e1e4e8;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

h1 {
    font-size: 24px;
    color: #24292e;
}

.status {
    display: flex;
    align-items: center;
    gap: 15px;
}

.status-indicator {
    display: flex;
    align-items: center;
    gap: 5px;
    color: #586069;
}

.status-indicator::before {
    content: '';
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #ffd33d;
}

.status-indicator.connected::before {
    background: #28a745;
}

.editor-container {
    position: relative;
    padding: 30px;
}

#editor {
    min-height: 400px;
    font-size: 16px;
    line-height: 1.6;
    outline: none;
    color: #24292e;
}

#editor:empty:before {
    content: attr(placeholder);
    color: #999;
}

.info {
    background: #f6f8fa;
    padding: 15px 30px;
    text-align: center;
    color: #586069;
    font-size: 14px;
}

.remote-cursor {
    position: absolute;
    width: 2px;
    height: 20px;
    transition: all 0.1s ease;
}

.remote-cursor::after {
    content: attr(data-user);
    position: absolute;
    top: -20px;
    left: 0;
    background: inherit;
    padding: 2px 6px;
    border-radius: 3px;
    font-size: 11px;
    color: white;
    white-space: nowrap;
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Client-Side Logic

Now let's add the JavaScript that handles local editing and synchronization with the server.

// client/editor.js
class CollaborativeEditor {
  constructor() {
    this.socket = io();
    this.editor = document.getElementById('editor');
    this.cursorsContainer = document.getElementById('cursors');
    this.statusIndicator = document.getElementById('connection-status');
    this.userCount = document.getElementById('user-count');

    this.localOperations = [];
    this.remoteOperations = [];
    this.documentVersion = 0;
    this.isComposing = false;

    this.setupEventListeners();
    this.setupSocketHandlers();
  }

  setupEventListeners() {
    // Track composition for better IME support
    this.editor.addEventListener('compositionstart', () => {
      this.isComposing = true;
    });

    this.editor.addEventListener('compositionend', () => {
      this.isComposing = false;
    });

    // Handle input events
    this.editor.addEventListener('input', (e) => {
      if (this.isComposing) return;

      this.handleLocalChange(e);
    });

    // Track cursor position
    this.editor.addEventListener('selectionchange', () => {
      const selection = window.getSelection();
      if (selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const position = this.getCaretPosition(range);
        this.socket.emit('cursor-position', position);
      }
    });
  }

  setupSocketHandlers() {
    this.socket.on('connect', () => {
      console.log('Connected to server');
      this.statusIndicator.textContent = 'Connected';
      this.statusIndicator.classList.add('connected');
    });

    this.socket.on('disconnect', () => {
      this.statusIndicator.textContent = 'Disconnected';
      this.statusIndicator.classList.remove('connected');
    });

    this.socket.on('document-state', (data) => {
      this.editor.textContent = data.content;
      this.documentVersion = data.version;
    });

    this.socket.on('remote-operation', (op) => {
      this.applyRemoteOperation(op);
    });

    this.socket.on('cursor-update', (data) => {
      this.updateRemoteCursor(data.userId, data.position);
    });

    this.socket.on('user-left', (userId) => {
      this.removeRemoteCursor(userId);
    });
  }

  handleLocalChange(event) {
    const inputType = event.inputType;

    if (inputType === 'insertText' || inputType === 'insertCompositionText') {
      const data = event.data;
      if (data) {
        const position = this.getCaretPosition();
        const operation = {
          type: 'insert',
          character: {
            char: data,
            position: this.generatePosition(position),
            id: this.generateId(),
            timestamp: Date.now()
          }
        };

        this.socket.emit('operation', operation);
        this.localOperations.push(operation);
      }
    } else if (inputType === 'deleteContentBackward') {
      // Handle deletion
      const position = this.getCaretPosition();
      if (position > 0) {
        const operation = {
          type: 'delete',
          position: position - 1
        };

        this.socket.emit('operation', operation);
        this.localOperations.push(operation);
      }
    }
  }

  applyRemoteOperation(operation) {
    const currentSelection = this.saveSelection();

    // Apply the operation to the editor
    if (operation.type === 'insert') {
      const text = this.editor.textContent;
      const insertPos = this.findInsertPosition(operation.character.position);

      this.editor.textContent = 
        text.slice(0, insertPos) + 
        operation.character.char + 
        text.slice(insertPos);

    } else if (operation.type === 'delete') {
      const text = this.editor.textContent;
      this.editor.textContent = 
        text.slice(0, operation.position) + 
        text.slice(operation.position + 1);
    }

    // Restore cursor position
    this.restoreSelection(currentSelection);
  }

  generatePosition(index) {
    // Simplified position generation
    return index + Math.random() * 0.01;
  }

  generateId() {
    return Math.random().toString(36).substr(2, 9);
  }

  findInsertPosition(position) {
    // Find the correct index for the given position
    // In a real implementation, this would use the CRDT structure
    return Math.floor(position);
  }

  getCaretPosition() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return 0;

    const range = selection.getRangeAt(0);
    const preRange = range.cloneRange();
    preRange.selectNodeContents(this.editor);
    preRange.setEnd(range.endContainer, range.endOffset);

    return preRange.toString().length;
  }

  saveSelection() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return null;

    const range = selection.getRangeAt(0);
    return {
      start: range.startOffset,
      end: range.endOffset
    };
  }

  restoreSelection(saved) {
    if (!saved) return;

    const selection = window.getSelection();
    const range = document.createRange();

    try {
      range.setStart(this.editor.firstChild || this.editor, saved.start);
      range.setEnd(this.editor.firstChild || this.editor, saved.end);

      selection.removeAllRanges();
      selection.addRange(range);
    } catch (e) {
      // Handle range errors gracefully
      console.warn('Could not restore selection:', e);
    }
  }

  updateRemoteCursor(userId, position) {
    let cursor = document.getElementById(`cursor-${userId}`);

    if (!cursor) {
      cursor = document.createElement('div');
      cursor.id = `cursor-${userId}`;
      cursor.className = 'remote-cursor';
      cursor.style.background = this.getColorForUser(userId);
      cursor.setAttribute('data-user', `User ${userId.substr(0, 4)}`);
      this.cursorsContainer.appendChild(cursor);
    }

    // Position the cursor
    const rect = this.editor.getBoundingClientRect();
    cursor.style.left = `${position * 7}px`; // Approximate character width
    cursor.style.top = '0px';
  }

  removeRemoteCursor(userId) {
    const cursor = document.getElementById(`cursor-${userId}`);
    if (cursor) {
      cursor.remove();
    }
  }

  getColorForUser(userId) {
    const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4'];
    const index = userId.charCodeAt(0) % colors.length;
    return colors[index];
  }
}

// Initialize the editor when page loads
document.addEventListener('DOMContentLoaded', () => {
  const editor = new CollaborativeEditor();
});
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Always save and restore the cursor position when applying remote operations. This prevents the user's cursor from jumping around as others type.

Handling Edge Cases and Conflicts

Real-world collaborative editing faces several challenges. Let's address the most common ones:

Network Latency and Operation Ordering

When operations arrive out of order due to network delays, our CRDT's position-based system ensures they still merge correctly:

// Add to CRDT class
handleOutOfOrderOperation(op, expectedVersion) {
  if (op.version <= this.version) {
    // Operation already applied or outdated
    return false;
  }

  // Apply operation and mark any gaps
  this.applyOperation(op);

  if (op.version > this.version + 1) {
    // We're missing some operations
    this.requestMissingOperations(this.version, op.version);
  }

  return true;
}

requestMissingOperations(fromVersion, toVersion) {
  this.socket.emit('request-operations', {
    from: fromVersion,
    to: toVersion
  });
}
Enter fullscreen mode Exit fullscreen mode

Handling Large Documents

For documents with thousands of characters, optimize the CRDT structure:

class OptimizedCRDT {
  constructor() {
    // Use a balanced tree instead of array for O(log n) operations
    this.tree = new BTree();
    this.tombstones = new Set(); // Track deleted characters
  }

  insert(index, char, userId) {
    const position = this.generatePosition(index);
    const node = {
      position,
      char,
      id: uuidv4(),
      userId,
      deleted: false
    };

    this.tree.insert(position, node);
    return node;
  }

  delete(charId) {
    // Mark as deleted instead of removing (tombstone pattern)
    const node = this.tree.findById(charId);
    if (node) {
      node.deleted = true;
      this.tombstones.add(charId);
    }
  }

  compact() {
    // Periodically remove tombstones when safe
    if (this.tombstones.size > 1000) {
      this.tree.removeDeleted();
      this.tombstones.clear();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Common Pitfall: Don't immediately delete characters from the CRDT structure. Use tombstones (marking as deleted) to handle out-of-order delete operations correctly.

Testing Your Collaborative Editor

Let's add tests to ensure our CRDT handles concurrent edits correctly:

// test/crdt.test.js
const CRDT = require('../server/crdt');

describe('CRDT Concurrent Operations', () => {
  test('Concurrent inserts at same position', () => {
    const doc1 = new CRDT();
    const doc2 = new CRDT();

    // Simulate two users inserting at position 0
    const op1 = doc1.insert(0, 'A', 'user1');
    const op2 = doc2.insert(0, 'B', 'user2');

    // Apply operations in different orders
    doc1.applyOperation(op2);
    doc2.applyOperation(op1);

    // Both documents should have the same final state
    expect(doc1.getText()).toBe(doc2.getText());
  });

  test('Interleaved inserts', () => {
    const doc = new CRDT();

    // User 1 types "ac"
    const op1 = doc.insert(0, 'a', 'user1');
    const op3 = doc.insert(1, 'c', 'user1');

    // User 2 inserts 'b' between them
    const op2 = doc.insert(1, 'b', 'user2');

    expect(doc.getText()).toBe('abc');
  });
});
Enter fullscreen mode Exit fullscreen mode

To test with multiple users:

  1. Open multiple browser tabs
  2. Navigate to http://localhost:3000
  3. Start typing in different tabs simultaneously
  4. Verify that all tabs show the same content

Performance Optimization

For production use, consider these optimizations:

Debouncing Operations

class DebouncedEditor extends CollaborativeEditor {
  constructor() {
    super();
    this.pendingOperations = [];
    this.flushTimer = null;
  }

  queueOperation(op) {
    this.pendingOperations.push(op);

    // Clear existing timer
    clearTimeout(this.flushTimer);

    // Flush after 50ms of inactivity
    this.flushTimer = setTimeout(() => {
      this.flushOperations();
    }, 50);
  }

  flushOperations() {
    if (this.pendingOperations.length === 0) return;

    // Batch send operations
    this.socket.emit('batch-operations', this.pendingOperations);
    this.pendingOperations = [];
  }
}
Enter fullscreen mode Exit fullscreen mode

Compression for Large Documents

// Server-side compression
const zlib = require('zlib');

socket.on('request-document', () => {
  const content = document.getText();

  if (content.length > 10000) {
    // Compress large documents
    zlib.gzip(content, (err, compressed) => {
      if (!err) {
        socket.emit('document-compressed', compressed);
      }
    });
  } else {
    socket.emit('document-state', { content });
  }
});
Enter fullscreen mode Exit fullscreen mode

Best Practice: Always batch operations when possible and implement compression for documents over 10KB to reduce bandwidth usage.

Deployment Considerations

Scaling with Redis

For multiple server instances, use Redis for state synchronization:

const Redis = require('ioredis');
const redis = new Redis();

// Publish operations to all server instances
socket.on('operation', async (op) => {
  // Apply locally
  document.applyOperation(op);

  // Publish to other servers
  await redis.publish('operations', JSON.stringify({
    room: socket.roomId,
    operation: op
  }));
});

// Subscribe to operations from other servers
redis.subscribe('operations');
redis.on('message', (channel, message) => {
  const data = JSON.parse(message);
  io.to(data.room).emit('remote-operation', data.operation);
});
Enter fullscreen mode Exit fullscreen mode

Persistence

Store document snapshots for recovery:

class PersistentCRDT extends CRDT {
  async saveSnapshot() {
    const snapshot = {
      characters: this.characters,
      version: this.version,
      timestamp: Date.now()
    };

    await db.saveSnapshot(this.documentId, snapshot);
  }

  async loadSnapshot(documentId) {
    const snapshot = await db.getSnapshot(documentId);
    if (snapshot) {
      this.characters = snapshot.characters;
      this.version = snapshot.version;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When NOT to Use CRDTs

While CRDTs are powerful, they're not always the right choice:

  • Simple turn-based editing: If users edit sequentially, traditional locking is simpler
  • Structured data with complex validation: CRDTs work best with simple data types
  • Strict consistency requirements: CRDTs provide eventual consistency, not strong consistency
  • Limited resources: CRDT metadata can consume significant memory for large documents

Consider alternatives like Operational Transformation (OT) for simpler use cases or when you have a reliable central server.

Conclusion

You've built a real-time collaborative text editor that handles concurrent edits gracefully using WebSockets and CRDTs. This foundation can scale from simple note-taking apps to complex collaborative IDEs.

Key Takeaways:

  • WebSockets enable real-time bidirectional communication with minimal latency
  • CRDTs automatically resolve conflicts without central coordination
  • Position-based ordering maintains consistency across distributed clients
  • Proper error handling and optimization are crucial for production systems

Next Steps:

  1. Add user authentication and document permissions
  2. Implement rich text formatting with operation transforms
  3. Add offline support with local storage and sync
  4. Explore advanced CRDT libraries like Yjs or Automerge

Additional Resources


Found this helpful? Leave a comment below or share with your network!

Questions or feedback? I'd love to hear about your experience building collaborative features in the comments.

Top comments (0)