All posts
5 Sep 2026

Building a Real-Time Collaborative Rich Text Editor with TipTap, Yjs, and WebSockets

A practical, code-heavy architectural guide on combining TipTap with Yjs and WebSockets in Node.js to implement a Google Docs-style real-time rich text editor.

Building a Real-Time Collaborative Rich Text Editor with TipTap, Yjs, and WebSockets

Building a Google Docs-style collaborative editor used to require complex, proprietary infrastructure. Today, modern open-source primitives make it entirely feasible to build a robust, real-time rich text editor from scratch.

In this architectural and practical guide, we will build a real-time collaborative text editor using TipTap (a headless rich text editor built on ProseMirror), Yjs (a High-Performance Conflict-free Replicated Data Type framework), and a custom WebSocket server built with Node.js.


Understanding the Architecture

Before writing code, let’s understand how data flows in a real-time collaborative system. Traditional client-server models fail here because network latency means users can make simultaneous edits. If the server simply overwrites state based on who saved last, data gets lost.

To solve this, we use CRDTs (Conflict-free Replicated Data Types) via Yjs. CRDTs guarantee that multiple nodes can independently update local state without coordination, and as long as all updates are eventually shared, all replicas will converge to the exact same state.

code
+-----------------------+         WebSocket         +-----------------------+
|  Client A (TipTap)    | <=======================> |                       |
+-----------------------+                           |    Node.js Server     |
         ^                                          |     (y-websocket)     |
         | Yjs Sync                                 |                       |
         v                                          |                       |
+-----------------------+         WebSocket         |                       |
|  Client B (TipTap)    | <=======================> |                       |
+-----------------------+                           +-----------------------+

The architecture consists of three core layers:

  1. The Presentation Layer (TipTap + ProseMirror): Renders the DOM and captures user keystrokes.
  2. The Synchronization Layer (Yjs): Manages the document state as a CRDT and generates compact binary updates.
  3. The Transport Layer (WebSockets + Node.js): Relays these binary updates between clients.

Step 1: Setting Up the Node.js WebSocket Server

We need a backend server that accepts WebSocket connections and acts as a relay for Yjs updates. While we could write custom WebSocket routing, the y-websocket package provides a ready-to-use binding that handles room management and message broadcasting out of the box.

First, initialize your backend project:

mkdir colab-editor-backend
cd colab-editor-backend
npm init -y
npm install ws yjs y-websocket

Now, create server.js:

const http = require('http');
const { Server } = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');

// Create a standard HTTP server
const server = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' });
  response.end('Yjs WebSocket Server is running\n');
});

// Initialize the WebSocket server on top of the HTTP server
const wss = new Server({ server });

wss.on('connection', (conn, req) => {
  console.log('New client connected:', req.socket.remoteAddress);
  
  // setupWSConnection handles document loading, broadcasting updates,
  // and awareness protocols (cursors, presence) automatically.
  setupWSConnection(conn, req);
});

const PORT = process.env.PORT || 1234;
server.listen(PORT, () => {
  console.log(`Server is listening on port ${PORT}`);
});

Run your server using node server.js. This backend is now fully capable of synchronizing any number of Yjs documents across multiple rooms based on the WebSocket URL path.


Step 2: Configuring the Frontend with TipTap and Yjs

On the client side, we need to bind TipTap’s editor state to a Yjs document (Y.Doc) and connect that document to our Node.js WebSocket server using y-websocket.

Install the required frontend dependencies (assuming a modern frontend setup like Vite, React, or Vue):

npm install @tiptap/core @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor yjs y-websocket

Here is how you initialize the editor in your JavaScript/TypeScript application:

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// 1. Initialize the Yjs document
const ydoc = new Y.Doc();

// 2. Connect to the Node.js WebSocket server
// The second argument 'document-room-name' isolates different documents
const wsProvider = new WebsocketProvider(
  'ws://localhost:1234',
  'document-room-name',
  ydoc
);

wsProvider.on('status', event => {
  console.log('WebSocket connection status:', event.status); // 'connected' or 'disconnected'
});

// 3. Initialize TipTap with Collaboration extensions
const editor = new Editor({
  element: document.querySelector('#editor'),
  extensions: [
    StarterKit.configure({
      // History must be disabled when using Yjs collaboration
      history: false,
    }),
    Collaboration.configure({
      document: ydoc,
    }),
    CollaborationCursor.configure({
      provider: wsProvider,
      user: {
        name: 'User ' + Math.floor(Math.random() * 100),
        color: '#' + Math.floor(Math.random()*16777215).toString(16),
      },
    }),
  ],
});

// Clean up on window close
window.addEventListener('beforeunload', () => {
  wsProvider.destroy();
  ydoc.destroy();
});

Crucial Architecture Note: You must disable the built-in history extension (history: false) in TipTap’s StarterKit. If left enabled, TipTap’s local undo/redo stack will conflict with Yjs’s distributed undo manager, leading to unpredictable state corruption.


Step 3: Handling Persistence on the Backend

By default, the y-websocket reference server stores document states strictly in memory. If the Node.js process restarts, all document data is wiped out.

To make this production-ready, we need to intercept document updates on the server and persist them to a database (such as PostgreSQL, MongoDB, or Redis).

Let’s update our Node.js backend to bind persistence callbacks to the Yjs document lifecycle using y-websocket’s persistence provider hooks:

const http = require('http');
const { Server } = require('ws');
const { setupWSConnection, setPersistence } = require('y-websocket/bin/utils');
const Y = require('yjs');
const fs = require('fs');
const path = require('path');

// Mock database persistence layer using the file system
const PERSISTENCE_DIR = path.join(__dirname, 'db');
if (!fs.existsSync(PERSISTENCE_DIR)) {
  fs.mkdirSync(PERSISTENCE_DIR);
}

setPersistence({
  // Bind a function to load state when a room is initialized
  bindState: async (docName, ydoc) => {
    const filePath = path.join(PERSISTENCE_DIR, `${docName}.bin`);
    
    try {
      if (fs.existsSync(filePath)) {
        const buffer = fs.readFileSync(filePath);
        Y.applyUpdate(ydoc, new Uint8Array(buffer));
        console.log(`Loaded state for room: ${docName}`);
      }
    } catch (err) {
      console.error(`Failed to load state for ${docName}:`, err);
    }

    // Listen for updates and write them back to persistence
    ydoc.on('update', async (update) => {
      const state = Y.encodeStateAsUpdate(ydoc);
      fs.writeFileSync(filePath, Buffer.from(state));
    });
  },
  
  // Optional write state hook if periodic batching is required
  writeState: async (docName, ydoc) => {
    const filePath = path.join(PERSISTENCE_DIR, `${docName}.bin`);
    const state = Y.encodeStateAsUpdate(ydoc);
    fs.writeFileSync(filePath, Buffer.from(state));
    console.log(`Persisted state for room: ${docName}`);
  }
});

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Persistent Yjs Server Running\n');
});

const wss = new Server({ server });

wss.on('connection', (conn, req) => {
  setupWSConnection(conn, req);
});

server.listen(1234, () => {
  console.log('Persistent server running on port 1234');
});

Scaling Consideration: Moving Beyond the File System

While writing binary snapshots to disk works for single-instance deployments, production environments require horizontal scaling behind a load balancer. To achieve this:

  1. Store binary updates (Y.encodeStateAsUpdate) inside a relational database like PostgreSQL using BYTEA columns or MongoDB using GridFS.
  2. Use Redis Pub/Sub to broadcast WebSocket frame updates across multiple clustered Node.js server instances.

Step 4: Adding Awareness and Cursors

One of the most engaging features of collaborative editors is seeing other users’ cursors and selections in real-time. Because we configured @tiptap/extension-collaboration-cursor on the client, the heavy lifting is already done.

However, you need to ensure your CSS includes styles for the remote selection indicators injected into the DOM by TipTap:

/* Remote selection and cursor styling */
.collaboration-cursor__caret {
  position: relative;
  margin-left: -1px;
  margin-right: -1px;
  border-left: 1px solid #000;
  border-right: 1px solid #000;
  word-break: normal;
  pointer-events: none;
}

.collaboration-cursor__label {
  position: absolute;
  top: -1.4em;
  left: -1px;
  font-size: 11px;
  font-style: normal;
  font-weight: 600;
  line-height: normal;
  user-select: none;
  color: #fff;
  padding: 1px 4px;
  border-radius: 3px;
  white-space: nowrap;
  pointer-events: none;
}

The y-websocket provider automatically hooks into Yjs’s built-in Awareness protocol, transmitting cursor coordinates, user names, and custom metadata to all connected peers in the same room without hitting your database.


Best Practices and Production Pitfalls

When deploying a collaborative editing stack to production, keep these architectural guidelines in mind:

  • Garbage Collection: Yjs retains history to resolve conflicts cleanly. For long-lived documents, ensure you periodically encode and compact state using Y.encodeStateAsUpdate(ydoc) to prune obsolete deletion vectors.
  • Authentication & Authorization: Validate user permissions during the initial WebSocket handshake request (req object in wss.on('connection')), rather than trusting the client room path blindly.
  • Connection Resilience: Network drops happen. The y-websocket provider handles automatic reconnection out of the box, buffering local changes made offline and pushing them to the server upon reconnection.

Conclusion

By combining TipTap’s extensible rich text API, Yjs’s mathematically sound CRDT engine, and a lightweight Node.js WebSocket backend, you can build a lightning-fast, highly resilient real-time collaboration engine with minimal boilerplate. This stack scales gracefully, eliminates complex operational overhead, and provides an exceptional user experience.

More posts