
Yjs: When Your Backend is Just a Collaborative Data Structure
Building real-time collaborative features usually means diving deep into WebSockets, CRDTs, and complex backend state management. Yjs changes that by providing shared data types that handle all the hard parts, letting you focus on the UI.
by Sunil Band
Why Collaboration Is Such a Headache
We've all been there: a client wants a 'Google Docs-like' experience. Instantly, your mind goes to WebSockets, operational transforms (OT), or conflict-free replicated data types (CRDTs). You start envisioning complex server logic, race conditions, and a deployment nightmare. Most solutions either force you into a specific backend or require you to become an expert in distributed systems just to get two users editing the same text field.
This complexity often pushes real-time collaboration into the 'nice-to-have' bucket, or worse, leads to a simplified, eventually consistent model that constantly frustrates users. The truth is, building robust, performant, and reliable real-time collaboration from scratch is hard. That's where Yjs comes in.
Yjs: Shared Data Structures, Not Just Shared State
Yjs isn't just another WebSocket wrapper. It's a framework for building highly concurrent and reliable collaborative applications by abstracting away the complexities of real-time state synchronization. At its core, Yjs provides shared data types—think Y.Text, Y.Map, Y.Array—that automatically handle all the merging and conflict resolution under the hood using CRDTs.
The magic is that these Yjs data types can be manipulated locally, and Yjs automatically broadcasts and merges changes across all connected clients. This means you're no longer thinking about sending diffs or managing version histories manually. You just interact with your data structure, and Yjs takes care of making sure everyone's view is eventually consistent and correctly merged, even with offline edits.
Getting Started with Yjs
Let's build a simple collaborative text editor. We'll use Yjs for the shared document state and connect it with a basic textarea element. For real-time communication, we'll use y-websocket, a simple WebSocket provider for Yjs.
First, install the necessary packages:
npm install yjs y-websocketNow, let's set up a minimal client. This example uses a simple textarea to demonstrate how changes are synchronized. Imagine this running in two different browser tabs.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// Create a Yjs document
const ydoc = new Y.Doc();
// Connect to a WebSocket provider. Replace with your actual WebSocket server URL.
// For local development, you can run a y-websocket server (npm install y-websocket-server)
// and start it with `y-websocket-server`. Default port is 1234.
const provider = new WebsocketProvider(
'ws://localhost:1234',
'my-collaborative-room', // A unique room name for this document
ydoc
);
// Get a shared text type. This is the collaborative string.
const ytext = ydoc.getText('codemirror'); // 'codemirror' is the key for this text block
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
// --- Binding Yjs to the textarea ---
// Initialize textarea with current Yjs content
textarea.value = ytext.toString();
// When textarea changes, update Yjs
textarea.addEventListener('input', () => {
// Apply changes to Yjs text. This intelligently diffs and applies.
// This method is robust, but for simple textareas, a full replace works too.
// For more complex editors like CodeMirror, you'd integrate with their change events.
ytext.delete(0, ytext.length); // Clear existing content
ytext.insert(0, textarea.value); // Insert new content
});
// When Yjs changes, update textarea
ytext.observe(event => {
// Prevent infinite loops if the change originated from this textarea
if (!event.transaction.local) {
textarea.value = ytext.toString();
}
});
// Optional: Log connection status
provider.on('status', event => {
console.log('WebSocket connection status:', event.status); // 'connecting', 'connected', 'disconnected'
});
// For a real editor, you'd use a binding like y-codemirror or y-prosemirror
// These bindings handle the intricate synchronization much more efficiently.
// Example with CodeMirror (requires y-codemirror and CodeMirror 6):
/*
import { yCollab, yUndoManagerKeymap, ySyncFacet } from 'y-codemirror';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
const ytext = ydoc.getText('codemirror');
const state = EditorState.create({
doc: ytext.toString(),
extensions: [
ySyncFacet.of(ytext), // Link Yjs text to editor state
yCollab(ytext, provider.awareness), // Collaboration extension
keymap.of(yUndoManagerKeymap) // Undo/Redo management
]
});
const view = new EditorView({
state,
parent: document.body
});
*/This simple setup demonstrates the core idea: you're working with ytext as if it were a local string, and Yjs handles the distributed consistency. For a robust integration with rich text editors, Yjs provides specific bindings like y-codemirror, y-prosemirror, and y-quill. These bindings are crucial because they efficiently translate granular editor operations into Yjs changes and vice-versa, avoiding full content replacements on every keystroke.
The Power of CRDTs Under the Hood
This is where Yjs truly shines. Traditional real-time collaboration often relies on Operational Transformation (OT), which requires a central server to serialize and apply operations in a strict order. This makes offline editing and peer-to-peer challenging, and the server becomes a single point of failure and a bottleneck.
Yjs uses CRDTs (Conflict-Free Replicated Data Types). The key insight of CRDTs is that certain data structures (like lists, text, or maps) can be designed such that concurrent operations on them can be applied in any order across different replicas, and they will always converge to the same consistent state without needing a central arbiter or complex conflict resolution logic. This is a game-changer for distributed systems.
What this means for you as a developer:
- Offline-first capabilities: Users can continue editing even when disconnected. Changes are queued and synchronized automatically when they come back online.
- Peer-to-peer potential: While
y-websocketuses a central server for signaling, Yjs itself is peer-to-peer capable. You can use WebRTC providers (y-webrtc) to connect clients directly, reducing server load and latency. - Simplified backend: Your backend for collaboration becomes incredibly thin—often just a signaling server for WebSockets or WebRTC, not a complex state manager. The heavy lifting of consistency is pushed to the client-side Yjs libraries.
Beyond Text: Shared Maps and Arrays
Yjs isn't just for text. It provides other shared data types that are equally powerful:
-
Y.Map: A shared key-value store. Great for collaborative forms, settings, or metadata. -
Y.Array: A shared ordered list. Perfect for collaborative to-do lists, kanban boards, or ordered component lists. -
Y.XmlFragment/Y.XmlElement: For structured rich text or collaborative UI elements.
Let's see an example with Y.Map:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('ws://localhost:1234', 'my-shared-map-room', ydoc);
const ymap = ydoc.getMap('settings');
// Initial setup
if (ymap.size === 0) {
ymap.set('theme', 'dark');
ymap.set('fontSize', 16);
}
// Observe changes to the map
ymap.observe(event => {
console.log('Map changed:', event.keysChanged);
event.keysChanged.forEach(key => {
console.log(`Key '${key}' changed to:`, ymap.get(key));
});
});
// Simulate a change from one client
setTimeout(() => {
ymap.set('theme', 'light');
ymap.set('fontSize', 18);
}, 2000);
// Simulate another change from a different client (e.g., in another tab)
setTimeout(() => {
ymap.set('fontSize', 20); // Concurrent change to 'fontSize'
ymap.set('language', 'en-US');
}, 3000);
// You'll see that 'fontSize' will eventually converge to 20,
// and both 'theme' and 'language' will be correctly set.
// The exact final value for concurrent modifications depends on the CRDT's merge logic,
// often it's the 'last writer wins' based on the internal timestamp or client ID.This demonstrates how Y.Map automatically merges concurrent updates. The

When Your Emails Need to Be as Good as Your UI: React Email
Sending emails from your application often means dealing with HTML tables, inline styles, and inconsistent rendering across clients. It's a UX nightmare. React Email brings the component model and developer experience of React to building robust, beautiful emails.

When Your UI Needs to Break the Flat Screen: React and 3D with React Three Fiber
We've been building UIs on flat screens for decades. But what happens when you need something more? When data visualization demands depth, or an interactive product showcase needs a real sense of presence? React Three Fiber is the tool that lets you bring the full power of Three.js into your React a

When Your State Management Needs to Stop Thinking in Actions and Start Mutating
Many state management libraries force you into an 'actions and reducers' pattern. While powerful, it often adds unnecessary boilerplate for simple updates. Sometimes, you just need to directly modify state, and mutators offer a more ergonomic and intuitive approach, especially for deeply nested data


















