Apr 10, 2025
Yjs is a powerful JS library that enables real-time collaboration in web applications without requiring a central server. It embeds conflict resolution directly in the data structure, allowing for true offline-first experiences where users can work locally and sync automatically when online. In this article, I dive deep into how Yjs works under the hood.
If you want to see Yjs in action, continue to the next chapter where I’ll build a local-first markdown editor using SvelteKit and Yjs that shares data peer-to-peer.
Think about the last time you used Google Docs, Notion, or Figma with other people. Everyone types, everyone sees changes instantly, and nobody’s work gets lost.
Creating these experiences is challenging - we need to handle conflicts, sync users data, and maintain offline capabilities. Thankfully, technologies like Yjs now hide much of this complexity, so we don’t need a PhD in distributed systems to build collaborative applications that simply work.
To understand why Yjs matters, let’s first look at how collaborative editing evolved over the last years.
Early collaborative systems like Google Docs used a technology called Operational Transformation (OT). The fundamental concept is simple: track every change as an operation (like insert H at position 0
), send these operations to a central server, and let the server determine the correct order of operations.
While these ideas work well and are easy to reason about, this architecture comes with fundamental limitations. OT requires a central server to process all operations which inevitably creates performance bottlenecks. Ever received a “This document is overloaded” message in Google Docs when too many people are editing at the same time?
These limitations led researchers to look for a better approach.
CRDTs revolutionize collaboration with a different approach: embedding conflict resolution into the data structure itself. By following a simple set of rules and maintaing necessary metadata inside the document, each client can independently reach the same final document. No central authority is needed to arbitrate conflicts.
Early CRDTs had serious performance issues because the metadata needed for conflict resolution would often grow larger than the actual content. Newer implementations like Yjs solve these performance problems with clever optimizations.
So how does Yjs prevent conflicts when multiple people edit at the same time?
Easy, right? By knowing relative positions of each character and storing information of who and when inserted the character we can create a conflict-free history of our document.
To make this work effectively, Yjs relies on a fundamental data structure known as Items, which forms the backbone of its conflict resolution system.
Item
- The Core Building BlockItem
s are the basic units of content in Yjs. In a text document, each character in a text is stored as an Item
, which contains the following metadata:
// Simplified representation of a Yjs Item
class Item {
constructor(id, content, left, right) {
this.id = id; // Unique identifier (clientID + clock)
this.content = content; // The actual content (text, object, etc.)
this.left = left; // Reference to the item before this one
this.right = right; // Reference to the item after this one
}
}
When conflicts occur (like two users inserting at the same position), Yjs uses deterministic rules to resolve them:
This may seem basic, but it’s powerful: every device applying these rules independently will reach exactly the same document state, without any communication or negotiation. No central server needed to make these decisions - the rules are built into the data.
I know what you’re thinking - wouldn’t creating an Item
for every character cause massive memory usage? Here is where Yjs shines.
While early CRDT implementations created a separate Item
for each character in a document, Yjs optimizes this by understanding how we type text in our computers.
If we type hello
Yjs will store it as a single Item
, not five separate ones. This new Item
will only split when changes happen in the middle of it. This simple optimization dramatically reduces memory usage and improves performance.
As Kevin Jahns notes in his post Are CRDTs Suitable for Shared Editing?
Most CRDTs assign a unique ID to every character that was ever created in the document. In Yjs, the complete document structure of a 100k character conference paper consists of only 11k Item objects instead of 260k objects for individual insertions/deletions. This type of compound representation dramatically reduces memory usage.
When content is deleted in Yjs, it isn’t actually removed from the internal structure. Instead, it’s marked as deleted in a delete set
. This preserves the structural relationships between Items, ensuring consistent document reconstruction across all peers.
This approach keeps the relative positions of all content stable, avoiding the cascade of position changes that would happen if items were actually removed.
When changes are made to a Yjs document, Yjs generates compact update messages containing only the modified parts - not the entire document. These updates are binary-encoded instructions that precisely describe what changed, where it changed, and who made the change.
This approach means:
When a client receives an update, it can apply these instructions to its local document state without requiring the sender’s full context. This makes Yjs particularly efficient for large documents with frequent small changes - exactly the scenario in collaborative editing.
Each client in a Yjs system maintains a state vector that defines the known state of each user. This vector is essentially a set of tuples in the form of (client, clock) that tracks exactly which operations from each client have been processed.
State vectors serve two critical purposes:
Enough of theory, let’s see how we can use Yjs to build modern collaborative applications.
Every Yjs implementation starts with creating a Y.Doc
—a shared container that holds all collaborative data:
// Initialize the shared document
const ydoc = new Y.Doc();
// Each client gets a unique ID automatically
console.log(ydoc.clientID); // Unique client identifier
// Listen for updates to track changes
ydoc.on("update", update => {
// The update is a binary-encoded change that can be sent to other peers
console.log(update); // Uint8Array containing the update
});
Our Y.Doc
has a map to organize our collaborative data. This map associates string keys with content, similar to a JavaScript object. Here, we can store “shared types” - specialized data structures designed for different kinds of collaborative data needs.
// Text for collaborative editing (like a document)
const ytext = ydoc.getText("note-content");
// Map for key-value data (like metadata)
const ymeta = ydoc.getMap("note-metadata");
// Array for ordered lists (like todos)
const ytags = ydoc.getArray("note-tags");
// XML for tree structures (like rich text)
const yxml = ydoc.getXmlFragment("note-richtext");
Using shared types feels similar to working with native JavaScript objects:
// Working with shared text
ytext.insert(0, "Hello, world!"); // Insert text at position 0
ytext.delete(0, 5); // Delete 5 characters starting at position 0
ytext.toString(); // Convert to string
// Working with shared maps
ymeta.set("title", "My Note"); // Set a key-value pair
ymeta.get("title"); // Get a value
ymeta.has("title"); // Check if a key exists
// Working with shared arrays
ytags.push(["important"]); // Add to the end
ytags.delete(0, 1); // Delete one item at position 0
ytags.toArray(); // Convert to regular array
These operations handle the complexities of collaboration behind the scenes. When you manipulate a shared type, Yjs automatically updates your local document, generates compact update messages that can be efficiently transmitted to other peers, and applies conflict resolution rules whenever concurrent edits occur.
When making multiple related changes we can group them into transaction. Changes inside a transaction will fire a single event.
// Group related changes in a transaction
ydoc.transact(() => {
ytext.insert(0, "Title: ");
ymeta.set("created", new Date().toISOString());
ytags.push(["personal"]);
});
A robust collaboration framework must support both synchronization (how changes propagate between users) and persistence (how data survives across sessions). Yjs addresses both needs through its provider system, which offers flexible options for connecting users and storing data.
This provider connects to a WebSocket server that handles real-time message distribution. It automatically syncs document changes between all connected clients.
import { WebsocketProvider } from "y-websocket";
const wsProvider = new WebsocketProvider(
"wss://notes-sync.example.com",
"note-document-id",
ydoc
);
// Handle connection status
wsProvider.on("status", event => {
console.log("Connection status:", event.status);
});
WebRTC enables peer-to-peer connections, which can reduce latency and server load. It’s particularly useful for applications that need to function even if the central server becomes unavailable.
import { WebrtcProvider } from "y-webrtc";
const webrtcProvider = new WebrtcProvider("note-document-id", ydoc, {
signaling: ["wss://signaling.yjs.dev"],
});
// Monitor peer connections
webrtcProvider.on("peers", event => {
console.log("Connected peers:", event.webrtcPeers.length);
});
This provider ensures that documents persist between browser sessions. When the user reopens your app, their document loads from local storage—even if they’re offline.
import { IndexeddbPersistence } from "y-indexeddb";
const persistence = new IndexeddbPersistence("note-document-id", ydoc);
// Handle local storage syncing
persistence.on("synced", () => {
console.log("Document loaded from local storage");
});
Beyond document content, Yjs includes an awareness system for sharing user presence. This enables real-time visibility of collaborators’ cursors, selections, and edits as they happen. Without this layer, collaborative editing would feel disconnected as users wouldn’t know who’s working where.
Unlike document content, awareness information is ephemeral—it disappears when users disconnect.
// Get the awareness instance from your provider
const awareness = wsProvider.awareness;
// Set your information
awareness.setLocalStateField("user", {
name: "Alice",
color: "#1a73e8",
cursor: { position: 120, paragraph: 3 },
});
// Listen for changes from all users
awareness.on("change", changes => {
// Get everyone's current information
const states = awareness.getStates();
// Update UI with this information
updateUserList(states);
updateCursors(states);
});
The UndoManager
tracks which changes came from which user, ensuring that people only undo their own changes—crucial for collaborative editing.
import * as Y from "yjs";
// Create undo manager for shared text
const undoManager = new Y.UndoManager(ytext);
// Undo last local change
undoManager.undo();
// Redo previously undone change
undoManager.redo();
// Update UI based on stack status
undoManager.on("stack-item-added", () => {
updateUndoRedoButtons();
});
Snapshots are lightweight representations of document state that contain just enough information to reconstruct the document at a specific point in time. With snapshots, you can build features like version history, document comparisons, and point-in-time recovery.
// Capture current document state
const snapshot = Y.snapshot(ydoc);
// Convert to storable format
const encodedSnapshot = Y.encodeSnapshot(snapshot);
// Later, load a saved snapshot
const decodedSnapshot = Y.decodeSnapshot(encodedSnapshot);
// Create a document from the snapshot
const previousDoc = Y.createDocFromSnapshot(ydoc, decodedSnapshot);
Yjs enables truly local-first applications with several key benefits:
These benefits translate to real user advantages: work stays safe during internet outages, apps feel faster without server delays, and users maintain control of their data (with optional server backup).
Yjs brings back the speed of old desktop applications like Microsoft Word while adding the collaboration features we love from Google Docs.
While Google Docs revolutionized collaboration, it remains limited by central server dependency, performance issues, and privacy concerns. Local-first and Yjs addresses these challenges by keeping documents on your device and syncing when possible. This provides a responsive application with collaboration benefits without sacrificing control or reliability; the best of both worlds.
Yjs and CRDTs have changed the collaboration landscape by embedding conflict resolution directly in our data. This opens up entirely new possibilities for software letting us build apps that work offline yet collaborate seamlessly when reconnected, all without sacrificing performance or user control.
Also, collaborative peer-to-peer architectures are unlocked, where the central server is no longer the ruler, but rather an optional tool for backup and synchronization.
In the next chapter, we’ll build a collaborative note-taking app using Svelte, SvelteKit, and Yjs. We’ll create an application where multiple users can edit notes at the same time - without needing a central server. Devices will connect directly between each other, to share changes instantly.