Yjs - An Engine for Modern Collaborative Apps

Apr 10, 2025

TL;DR

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.

Why Collaborative Editing Matters

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.

The Evolution of Collaboration: From OT to CRDTs

To understand why Yjs matters, let’s first look at how collaborative editing evolved over the last years.

Operational Transformation (OT): The First Wave

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: The Breakthrough

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.

Diving Deeper - How Yjs Works Under the Hood

So how does Yjs prevent conflicts when multiple people edit at the same time?

User 0 inserts character 's'

User 0 inserts character 's'

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 Block

Items 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
  }
}

Conflict Resolution Made Simple

When conflicts occur (like two users inserting at the same position), Yjs uses deterministic rules to resolve them:

  1. If two Items want the same position, they’re ordered by their creator’s client ID
  2. Lower client IDs come first (a simple but effective tie-breaker)

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.

The Efficiency Secret: Shared Sequences

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.

Deletions Without Disruption

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.

Updates as Incremental Instructions

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:

  1. Bandwidth efficiency - Only sending what changed, not the entire document
  2. Conflict resilience - As updates contain the necessary metadata they can be applied in any order

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.

State Vectors: Tracking Document Knowledge

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:

  1. Synchronization efficiency - When clients connect, they exchange state vectors to determine which updates need to be sent
  2. Consistency verification - Clients can verify they have the same document state by comparing it’s vectors

Building Applications with Yjs

Enough of theory, let’s see how we can use Yjs to build modern collaborative applications.

1. The Y.Doc: Your Document Container

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
});

2. Shared Types for Different Data Needs

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");

3. Working with Shared Types

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.

4. Transactions for Atomic Changes

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"]);
});

Synchronization and Persistence

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.

WebSocket for Real-time Collaboration

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 for Peer-to-Peer Collaboration

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);
});

IndexedDB for Offline Persistence

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");
});

User Awareness

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);
});

Advanced Features

Collaborative Undo/Redo

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();
});

Document History and Snapshots

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);

The Local-First Advantage

Yjs enables truly local-first applications with several key benefits:

  1. Offline-first operation: Users can work without an internet connection
  2. Zero-latency editing: Changes appear instantly without server roundtrips
  3. Seamless synchronization: Changes merge automatically when connectivity returns
  4. Data ownership: User data lives on their device first, not just in the cloud
  5. Resilience: No single point of failure

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.

Conclusion and Next Steps

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.