Real time collaboration used to be a feature reserved for tools like Google Docs or Notion. You either paid for a SaaS product or spent months building your own solution from scratch. That has changed. With Conflict free Replicated Data Types (CRDTs) and WebSockets, you can build a collaborative text editor that handles concurrent edits, offline changes, and conflict resolution without a central server deciding who wins. In this guide, you will build a fully functional collaborative text editor using Yjs for CRDT logic and Node.js for the WebSocket layer. By the end, you will have a working editor where multiple users can type at the same time, see each other’s cursors, and never lose a single keystroke.
CRDTs let you build real time collaboration without a central conflict resolver. This tutorial walks through setting up a WebSocket server with Node.js, integrating Yjs for CRDT synchronization, and building a React frontend that supports live cursors and seamless multi user editing. You will learn the core patterns that power tools like Google Docs.
Why CRDTs Matter for Collaborative Editing
The traditional approach to collaborative editing involves Operational Transformation (OT). OT works by sending operations (insert character, delete character) to a server, which transforms them against other concurrent operations to maintain consistency. OT is powerful, but it requires a central server to coordinate transformations. That makes offline support tricky and adds complexity.
CRDTs take a different approach. Every client maintains a local copy of the document. Edits are applied locally first, then synced to peers. The data structure itself guarantees that all clients will converge to the same state, even if they receive updates in different orders. No central server needed for conflict resolution. This makes CRDTs ideal for peer to peer applications, offline first apps, and scenarios where latency is unpredictable.
Yjs is the most popular CRDT library for JavaScript. It uses a data structure called a linked list of character fragments, each with a unique identifier. When two users insert text at the same position, Yjs uses the identifiers to deterministically order the inserts. The result is a consistent document across all clients, every time.
What You Will Build
You will build a collaborative text editor with three core features:
- Real time synchronization using WebSockets
- CRDT based conflict resolution using Yjs
- Live cursor awareness so users can see where others are typing
The stack is straightforward:
- Node.js with the
wslibrary for the WebSocket server - Yjs for CRDT logic and document synchronization
- React with the
y-prosemirrorbinding for the editor UI - ProseMirror as the text editor framework
Prerequisites
Before you start, make sure you have these installed:
- Node.js 18 or later
- npm or yarn
- Basic familiarity with React hooks and functional components
- Some experience with WebSocket concepts
You do not need to be a CRDT expert. This tutorial will explain the key concepts as you go.
Step 1: Set Up the Project Structure
Create a new directory and initialize the project.
mkdir collab-editor
cd collab-editor
npm init -y
Install the server dependencies.
npm install ws yjs
npm install --save-dev nodemon
Create a server folder and add an index.js file.
mkdir server
touch server/index.js
For the client, you will use Vite with React.
npm create vite@latest client -- --template react
cd client
npm install yjs y-prosemirror prosemirror-view prosemirror-state prosemirror-model prosemirror-schema-basic prosemirror-example-setup
cd ..
Your folder structure should look like this:
collab-editor/
server/
index.js
client/
src/
App.jsx
main.jsx
package.json
Step 2: Build the WebSocket Server
The WebSocket server acts as a relay. It receives updates from one client and forwards them to all other connected clients. It does not resolve conflicts. That is the job of Yjs on the client side.
Open server/index.js and add the following code.
const { WebSocketServer } = require('ws');
const Y = require('yjs');
const wss = new WebSocketServer({ port: 1234 });
const docs = new Map();
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://localhost');
const docId = url.searchParams.get('docId') || 'default';
if (!docs.has(docId)) {
const ydoc = new Y.Doc();
docs.set(docId, ydoc);
}
const ydoc = docs.get(docId);
const awareness = new Y.Awareness(ydoc);
ws.send(Y.encodeStateAsUpdate(ydoc));
ws.on('message', (message) => {
const update = Y.decodeUpdate(message);
Y.applyUpdate(ydoc, update);
wss.clients.forEach((client) => {
if (client !== ws && client.readyState === ws.OPEN) {
client.send(message);
}
});
});
ws.on('close', () => {
if (wss.clients.size === 0) {
docs.delete(docId);
}
});
});
console.log('WebSocket server running on port 1234');
This server does a few important things:
- It creates a Yjs document for each
docId. Multiple documents can exist at the same time. - When a client connects, it sends the full document state so the client can initialize.
- When a client sends an update, it applies the update locally and broadcasts it to all other clients.
- When the last client disconnects, it cleans up the document.
Step 3: Understand the Yjs Sync Protocol
Yjs uses a binary protocol for synchronization. There are two main message types:
- State vector exchange: Clients share what they know to figure out what they are missing.
- Update propagation: Clients broadcast their local changes as binary updates.
In the server code above, Y.encodeStateAsUpdate(ydoc) creates a snapshot of the full document state. When a client sends a message, Y.applyUpdate(ydoc, message) merges the incoming changes.
This approach is efficient because updates are small and idempotent. Applying the same update twice has no side effects.
Blockquote: “CRDTs remove the need for a central conflict resolver. The data structure itself guarantees convergence. This is the key insight that makes peer to peer collaboration practical.” You will see this play out as you test the editor with multiple browser tabs.
Step 4: Build the React Client
Now let’s build the frontend. The client connects to the WebSocket server, creates a local Yjs document, and binds ProseMirror to it.
Open client/src/App.jsx and replace its contents.
import { useEffect, useRef } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { ProsemirrorBinding } from 'y-prosemirror';
import { EditorView } from 'prosemirror-view';
import { EditorState } from 'prosemirror-state';
import { schema } from 'prosemirror-schema-basic';
import { exampleSetup } from 'prosemirror-example-setup';
function App() {
const editorRef = useRef(null);
useEffect(() => {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('ws://localhost:1234', 'my-doc', ydoc);
const type = ydoc.getText('content');
const view = new EditorView(editorRef.current, {
state: EditorState.create({
schema,
plugins: [
...exampleSetup({ schema }),
new ProsemirrorBinding(type, provider.awareness).plugin,
],
}),
});
return () => {
view.destroy();
provider.destroy();
ydoc.destroy();
};
}, []);
return <div ref={editorRef} />;
}
export default App;
This code does the following:
- Creates a Yjs document and connects to the WebSocket server using
y-websocket. - Gets a shared text type called
content. - Creates a ProseMirror editor and binds it to the shared text type using
y-prosemirror. - The binding automatically handles cursor awareness.
Step 5: Add Cursor Awareness
One of the most satisfying features of collaborative editing is seeing other users’ cursors. Yjs has a built in awareness protocol that syncs cursor positions, user names, and colors.
Modify the App.jsx file to include awareness data.
useEffect(() => {
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('ws://localhost:1234', 'my-doc', ydoc);
const type = ydoc.getText('content');
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7'];
const names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];
const randomIndex = Math.floor(Math.random() * colors.length);
provider.awareness.setLocalStateField('user', {
name: names[randomIndex],
color: colors[randomIndex],
});
const view = new EditorView(editorRef.current, {
state: EditorState.create({
schema,
plugins: [
...exampleSetup({ schema }),
new ProsemirrorBinding(type, provider.awareness).plugin,
],
}),
});
return () => {
view.destroy();
provider.destroy();
ydoc.destroy();
};
}, []);
Now each user gets a random name and color. When they move their cursor, other users will see a colored cursor with the user’s name.
Step 6: Run the Application
Start the server first.
node server/index.js
In a separate terminal, start the client.
cd client
npm run dev
Open two browser tabs at http://localhost:5173. Start typing in one tab. You will see the text appear in the other tab in real time. Move your cursor around. You will see the cursor position reflected in the other tab.
Step 7: Test Conflict Resolution
The real test of a collaborative editor is how it handles concurrent edits. Try this:
- Open three tabs.
- In tab 1, type “Hello “.
- In tab 2, type “World” at the same position.
- In tab 3, observe the result.
With CRDTs, both edits are preserved. Yjs orders them deterministically based on their unique identifiers. The result is consistent across all three tabs.
This works because each character insert in Yjs has a unique ID that includes a client ID and a clock value. When two inserts happen at the same position, Yjs sorts them by ID. The order is deterministic and consistent across all clients.
Common Pitfalls and How to Avoid Them
Here is a table of common mistakes and their fixes.
| Pitfall | Symptom | Fix |
|---|---|---|
Forgetting to call Y.applyUpdate on the server |
Clients never sync | Apply incoming updates to the server side doc |
| Using different doc IDs | Clients talk to different documents | Ensure all clients use the same docId |
| Not destroying providers on unmount | Memory leaks and stale connections | Clean up in the useEffect return |
Missing y-websocket dependency |
Provider fails to connect | Install y-websocket in the client |
| Schema mismatch | ProseMirror throws errors | Use the same schema on all clients |
Performance Considerations
CRDTs perform well for text editing because updates are small. Each keystroke generates a tiny binary message (around 20 to 50 bytes). For documents under 100,000 characters, Yjs can handle hundreds of concurrent edits with sub 50 millisecond sync times.
If you plan to support very large documents or thousands of concurrent users, consider these optimizations:
- Use a WebSocket library that supports compression, like
permessage-deflate. - Implement pagination for document history.
- Use a dedicated sync server like
y-websocketwith multiple rooms.
For most projects, the basic setup covered in this tutorial will handle dozens of simultaneous editors without any tuning.
What Else You Can Build
Once you have the core editor working, you can extend it in many ways.
- Rich text formatting: Add bold, italic, and headings by extending the ProseMirror schema.
- Comments and suggestions: Use Yjs’s built in awareness protocol to broadcast comment data.
- Offline support: Combine Yjs with IndexedDB using
y-indexeddbto persist changes locally. - Slash commands: Implement a command menu that triggers on the “/” key.
- Version history: Store snapshots of the document at regular intervals using
Y.encodeStateAsUpdate.
If you are interested in other real time communication patterns, check out our guide on how to build a real time chat app with WebSockets and JavaScript. The WebSocket patterns overlap nicely.
Putting It All into Practice
Building a collaborative text editor with CRDTs and WebSockets is easier than you might think. The heavy lifting is done by Yjs, which handles conflict resolution, synchronization, and awareness. Your job is to wire up the WebSocket server, bind the editor, and let users connect.
The editor you built in this tutorial is production ready for small teams. You can deploy it on a VPS, add authentication, and start collaborating in less than an hour. The same patterns apply to collaborative drawing tools, code editors, and whiteboard apps.
Take the code from this guide and extend it. Add a feature that is meaningful to your users. That is how real tools are born.