System Design

Building Real-Time Collaborative Apps with Yjs & WebSockets

Dive deep into building robust real-time collaborative applications using Yjs for CRDT-based state management and WebSockets for efficient communication. Learn architecture, code examples, and best practices.

Khader Vali October 10, 2026 13 min read

Building Real-Time Collaborative Apps with Yjs & WebSockets

In today’s interconnected world, real-time collaboration is no longer a luxury but a fundamental expectation for many applications. From document editors like Google Docs to design tools, project management platforms, and even multiplayer games, the ability for multiple users to interact with shared data simultaneously and see changes instantly drives productivity and engagement. But how do you build such complex systems without falling into a pit of synchronization nightmares?

This article dives deep into building robust, scalable, and efficient real-time collaborative applications using two powerful technologies: Yjs for CRDT-based state management and WebSockets for real-time communication. As a senior software engineer, I’ve navigated the complexities of distributed systems, and I’m here to guide you through a battle-tested approach that simplifies what often seems like an insurmountable challenge.

The Challenge of Real-Time Collaboration

Before we jump into solutions, let’s understand the core problems real-time collaboration presents:

  • State Synchronization: How do you keep the application state consistent across multiple clients, especially when changes happen concurrently?
  • Conflict Resolution: What happens when two users edit the same piece of data simultaneously? Whose change wins, or how are both changes merged gracefully? Traditional “last-write-wins” often lead to data loss.
  • Latency and Responsiveness: Users expect instant feedback. How do you ensure changes propagate quickly without perceived lag?
  • Offline Support: Can users continue working even without an internet connection, and then merge their changes once they’re back online?
  • Scalability: How do you handle a growing number of users and concurrent editing sessions?
  • Awareness: How do you show users who else is online and what they’re currently doing (e.g., cursor positions, selections)?

These challenges have plagued developers for decades. Early solutions often involved complex operational transformation (OT) algorithms, famously used by Google Docs. While powerful, OT is notoriously difficult to implement correctly, debug, and extend. This is where a more modern, elegant solution emerges: Conflict-free Replicated Data Types (CRDTs).

CRDTs: The Backbone of Effortless Collaboration

Conflict-free Replicated Data Types (CRDTs) are data structures that can be replicated across multiple machines, updated independently and concurrently without coordination, and then merged without conflicts. The key property of CRDTs is that they are mathematically guaranteed to converge to the same state across all replicas, regardless of the order in which operations are applied, as long as all operations eventually arrive at all replicas.

Think of it like this: if you have a set of numbers and two people simultaneously add different numbers to their local copies of the set, when they merge their sets, the final set will contain all the numbers added by both, without any conflicts. This “eventual consistency” without requiring a centralized arbiter for every single change is what makes CRDTs so powerful for distributed systems.

There are two main types of CRDTs:

  • Operation-based CRDTs: Replicate the operations themselves (e.g., “add element X at index Y”). The operations must be commutative and associative.
  • State-based CRDTs: Replicate the entire state of the data structure. Merging involves a specific merge function that combines states.

Yjs primarily utilizes an operation-based approach, optimized for efficiency and minimal data transfer, making it incredibly suitable for web applications.

Why CRDTs are Superior for Real-Time Apps

  • Automatic Conflict Resolution: The most significant benefit. Developers don’t need to write complex conflict resolution logic for every data type.
  • Offline-First Capabilities: Changes can be made locally while offline and then synchronized seamlessly once the connection is restored.
  • Simplified Development: Building collaborative features becomes much simpler, abstracting away most of the synchronization complexities.
  • Eventual Consistency: Guarantees that all replicas will eventually reach the same state.
  • Decentralization Potential: Can work peer-to-peer without a central server for conflict resolution (though a server is often used for signaling and initial state distribution).

Introducing Yjs: The Collaboration Engine

Yjs is an open-source framework for building collaborative applications. It provides high-performance CRDT implementations for common data types (text, arrays, maps, XML elements) and a flexible architecture to integrate with various networking and persistence layers. Yjs is written in TypeScript, compiled to highly optimized JavaScript, and can be used in browsers and Node.js environments.

Key Features of Yjs

  • Rich Data Types:
    • Y.Doc: The central document store, a container for all shared types.
    • Y.Text: For collaborative text editing (like a rich text editor or textarea).
    • Y.Map: For collaborative key-value pairs (like a JSON object).
    • Y.Array: For collaborative lists.
    • Y.XmlElement / Y.XmlText: For collaborative DOM-like structures, useful for rich text editors.
  • Awareness: A built-in mechanism to share transient user information (e.g., cursor position, selection, name, color) without persisting it. This is crucial for a good UX in collaborative apps.
  • Undo/Redo Stack: Yjs automatically tracks changes, enabling robust undo/redo functionality out of the box.
  • Provider System: Yjs is agnostic to the underlying communication layer. It offers “providers” for various backends like WebSockets (`y-websocket`), WebRTC (`y-webrtc`), IndexedDB (`y-indexeddb`), and more.
  • Efficiency: Yjs operations are highly optimized, resulting in small data payloads and efficient merging algorithms.

WebSockets: The Real-Time Communication Layer

While Yjs handles the state synchronization logic, we need a mechanism to transmit the Yjs updates (called “updates” or “deltas”) between clients and the server in real-time. This is where WebSockets shine.

WebSockets provide a full-duplex communication channel over a single TCP connection. Unlike traditional HTTP requests, which are unidirectional and connectionless (requiring a new connection for each request, or long-polling hacks), WebSockets establish a persistent, bidirectional connection between the client and the server. This allows both parties to send data to each other at any time, with minimal overhead.

Why WebSockets for Yjs?

  • Low Latency: Once the connection is established, data can be sent immediately without the overhead of HTTP headers, leading to near real-time communication.
  • Full-Duplex: Both client and server can send messages independently and concurrently, which is ideal for broadcasting Yjs updates.
  • Persistent Connection: No need to repeatedly establish connections, reducing overhead and improving efficiency.
  • Efficiency: Smaller data frames compared to HTTP, further reducing network traffic.
  • Scalability (with proper design): A single WebSocket server can manage thousands of concurrent connections, making it suitable for collaborative applications.

<

Building Real-Time Collaborative Apps with Yjs & WebSockets
Generated Image

>

Architectural Overview: Yjs & WebSockets

Let’s visualize the architecture of a real-time collaborative application built with Yjs and WebSockets. Imagine a central server acting as a hub for multiple clients.

Components:

  1. Client Application (Browser):
    • Y.Doc Instance: Each client maintains its own local Y.Doc instance, which holds the current shared state.
    • Yjs Providers (e.g., y-websocket): A bridge that connects the local Y.Doc to the WebSocket server. It handles sending local Yjs updates to the server and applying incoming updates from the server to the local Y.Doc.
    • UI Framework (e.g., React, Vue, Svelte): Binds the Y.Doc’s shared types (Y.Text, Y.Map, etc.) to the user interface elements (e.g., a textarea, an interactive whiteboard).
    • Awareness State: The client sends its transient awareness information (e.g., cursor position) via the provider.
  2. WebSocket Server (Node.js):
    • WebSocket Server Implementation (e.g., ws library): Manages WebSocket connections from all clients.
    • Yjs WebSocket Provider Server (e.g., y-websocket server implementation): This is the crucial middleware. For each collaborative document, the server maintains its own “source of truth” Y.Doc.
      • When a client connects, the server sends the current state of the document.
      • When a client sends a Yjs update, the server applies it to its local Y.Doc.
      • After applying the update, the server broadcasts this *new* update (which might be a merged version if concurrent changes occurred on the server’s Y.Doc) to all other connected clients for that specific document.
    • Persistence Layer (Optional but Recommended): A database (e.g., PostgreSQL, MongoDB, SQLite) to store the Yjs document state. When the server starts or a document is requested, it loads the last saved state from the database. Periodically, or after significant changes, the server saves the current Y.Doc state to the database.

Data Flow: A Diagram in Words

  1. Initial Load:
    1. Client A connects to the WebSocket server.
    2. Server checks if the requested document (e.g., “document-123”) exists in its memory or persistence layer.
    3. If found, server loads the document’s full state (or a recent snapshot) into its server-side Y.Doc.
    4. Server sends this initial state to Client A.
    5. Client A receives the state, applies it to its local Y.Doc, and updates its UI.
  2. User A Makes a Change:
    1. User A types “hello” into a collaborative textarea on Client A.
    2. Client A’s UI updates its local Y.Text instance.
    3. Yjs generates a small “update” (delta) representing this change.
    4. y-websocket provider on Client A sends this update to the WebSocket server.
  3. Server Processes Change:
    1. WebSocket server receives the update from Client A.
    2. Server applies the update to its server-side Y.Doc for “document-123”.
    3. Server then broadcasts this update (or the resulting merged update) to all other clients connected to “document-123” (e.g., Client B, Client C).
  4. Other Clients Receive Change:
    1. Client B and Client C receive the update from the server.
    2. Their respective y-websocket providers apply the update to their local Y.Doc instances.
    3. Their UIs react to the Y.Doc changes and display “hello” in their textareas.
  5. Concurrent Changes (Conflict Resolution):
    1. While User A types “hello”, User B simultaneously types “world” in the same textarea on Client B.
    2. Client B generates an update and sends it to the server.
    3. The server receives updates from both A and B (potentially in slightly different orders).
    4. Because Yjs uses CRDTs, applying both updates to the server’s Y.Doc will result in a correctly merged state (e.g., “helloworld” or “worldhello” depending on the exact operations and CRDT logic, but *always* consistent).
    5. The server broadcasts the *final merged update* to all clients, ensuring everyone converges to the same consistent state.
  6. Awareness Updates:
    1. Client A periodically sends its awareness state (e.g., { cursor: 5, color: 'blue' }) via the y-websocket provider.
    2. The server broadcasts this awareness state to all other clients.
    3. Other clients update their UIs to show Client A’s blue cursor at position 5. These are transient and not part of the Y.Doc’s persistent state.

This architecture decouples the complex synchronization logic (handled by Yjs) from the communication layer (handled by WebSockets), making the system robust and relatively straightforward to implement.

Building Blocks: Step-by-Step Implementation

Let’s get our hands dirty and build a simple collaborative text editor. We’ll need a Node.js server and a simple HTML/JavaScript client.

1. Setting up the Server

We’ll use Node.js with the ws library for WebSockets and y-websocket for the Yjs integration.

Initialise project and install dependencies:

mkdir yjs-websocket-server
cd yjs-websocket-server
npm init -y
npm install ws yjs y-websocket

Server Code (server.js):

This server will manage multiple Y.Doc instances, one for each “document” requested by clients, identified by a URL parameter.

// server.js
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils'; // Utility for handling Yjs WebSocket connections

const PORT = process.env.PORT || 1234;

const wss = new WebSocketServer({ port: PORT });

console.log(`WebSocket server listening on port ${PORT}`);

wss.on('connection', (ws, req) => {
    // Extract document name from the URL, e.g., ws://localhost:1234/my-document-id
    const url = new URL(req.url, `http://${req.headers.host}`);
    const docName = url.pathname.substring(1); // Remove leading slash, e.g., 'my-document-id'

    if (!docName) {
        console.error('Connection rejected: No document name provided in URL.');
        ws.close(1008, 'Missing document name in URL (e.g., /my-document-id)');
        return;
    }

    console.log(`Client connected for document: ${docName}`);

    // setupWSConnection is a helper from y-websocket that manages Y.Doc lifecycle,
    // applies updates, handles awareness, and persistence (if configured).
    // It automatically creates and manages a Y.Doc instance for `docName`.
    setupWSConnection(ws, req, {
        docName: docName,
        // Optional: Add persistence here if you want to store Y.Doc state
        // persist: {
        //   bindState: async (docName, ydoc) => {
        //     // Load from database
        //     console.log(`Loading state for ${docName}`);
        //     // Example: const storedState = await db.getDocumentState(docName);
        //     // if (storedState) { Y.applyUpdate(ydoc, storedState); }
        //   },
        //   writeState: async (docName, ydoc) => {
        //     // Save to database
        //     console.log(`Saving state for ${docName}`);
        //     // Example: const state = Y.encodeStateAsUpdate(ydoc);
        //     // await db.saveDocumentState(docName, state);
        //   }
        // }
    });

    ws.on('close', () => {
        console.log(`Client disconnected from document: ${docName}`);
    });

    ws.on('error', (error) => {
        console.error(`WebSocket error for document ${docName}:`, error);
    });
});

// For gracefully handling server shutdown
process.on('SIGINT', () => {
    console.log('Shutting down WebSocket server...');
    wss.close(() => {
        console.log('WebSocket server closed.');
        process.exit(0);
    });
});

To run the server, use node server.js.

2. Setting up the Client

The client will be a simple HTML file with inline JavaScript. We’ll use yjs and y-websocket on the client side, binding a Y.Text type to a standard HTML textarea.

Initialise project and install dependencies (client):

For a browser environment, we usually bundle client-side dependencies. For simplicity, we’ll use a local `node_modules` and include a build step or directly link to a CDN. Let’s assume we’ll use a simple `webpack` or `parcel` setup in a real project, but for this example, we’ll keep it as plain JS as possible, potentially requiring the user to run `npm install yjs y-websocket` in a client project and bundle it.

For a quick demo, we can use a CDN for Yjs and y-websocket, or assume the user has a build process. Let’s write the HTML/JS that *would* use these modules after they’re bundled or loaded.

Client Code (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 Text Editor with Yjs & WebSockets</title>
<style>
body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; margin-top: 50px; }
textarea { width: 80%; max-width: 800px; height: 300px; padding: 15px; font-size: 1.1em; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); margin-bottom: 20px; }
.info { font-size: 0.9em; color: #555; margin-bottom: 10px; }
.awareness-list { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 10px; margin-top: 15px; }
.awareness-item { padding: 5px 10px; border-radius: 15px; background-color: #e0e0e0; font-size: 0.8em; }
.awareness-item.active { background-color: #d4edda; border-color: #28a745; color: #155724; }
</style>
</head>
<body>
<h1>Real-Time Collaborative Document</h1>
<div class="info">Open this page in multiple tabs or browsers and start typing!</div>
<div class="info">Document ID: <span id="documentId"></span></div>
<textarea id="y-textarea" placeholder="Start typing here..."></textarea>
<h3>Who's here?</h3>
<ul id="awareness-list" class="awareness-list">
<li>Loading...</li>
</ul>

<script type="module">
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// --- Configuration ---
const SERVER_URL = 'ws://localhost:1234';
const docIdParam = new URLSearchParams(window.location.search).get('doc');
const DOCUMENT_ID = docIdParam || 'my-first-collaborative-document'; // Default document ID

// --- Yjs Setup ---
const ydoc = new Y.Doc();
const ytext = ydoc.getText('codemirror'); // A shared Y.Text type for our editor
const awareness = ydoc.getAwareness(); // Yjs awareness for user presence

// --- Websocket Provider Setup ---
// Connect to the WebSocket server for the specified document ID
const provider = new WebsocketProvider(
SERVER_URL,
DOCUMENT_ID,
ydoc,
{ awareness: awareness }
);

provider.on('status', event => {
console.log(`WebSocket status: ${event.status}`); // logs "connecting", "connected", "disconnected"
});

// --- UI Binding ---
const textarea = document.getElementById('y-textarea');
const documentIdSpan = document.getElementById('documentId');
const awarenessList = document.getElementById('awareness-list');

documentIdSpan.textContent = DOCUMENT_ID;

// Apply Yjs changes to the textarea
ytext.observe(event => {
// Check if the change originated from the local user to avoid unnecessary updates
// (e.g., if you're using a rich text editor where the binding is more complex)
// For a simple textarea, we directly update it.
if (textarea.value !== ytext.toString()) {
const currentCursorPos = textarea.selectionStart;
textarea.value = ytext.toString();
// Restore cursor position if possible to provide a smooth UX
if (document.activeElement

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

DDD Tactical Patterns: Aggregates, VOs, Repositories

Jun 23, 2026 · 12 min read

Distributed Caching Strategies: Redis, Memcached, and CDN Patterns

Jul 17, 2026 · 17 min read

Full Stack TypeScript: tRPC & Prisma End-to-End

May 26, 2026 · 10 min read