Intro to yjs

Written by Basile Samel.

Published Jul 9, 2025. Last updated Jul 9, 2025.

yjs is a Conflict-Free Replicated Data Type (CRDT) library for Javascript.

I think it’s one of the most underrated pieces of open-source software I’ve ever worked with, so I decided to write a series of tutorials to help more developers use it productively.

First, let’s start with the basics.

Why Yjs Exists

Yjs was created to address the need for real-time, conflict-free data synchronization across multiple users and devices. Traditional approaches to collaboration, like operational transformation (OT), can be complex and require centralized coordination. CRDTs offer a more distributed and resilient solution.

Yjs brings the power of CRDTs to Javascript to make it easier to build collaborative / multiplayer apps like Google Docs, Figma, or Notion without having to reinvent the wheel.

It’s also a building block of offline-first web applications to create more resilient user experiences independently of network conditions.

The consequences for web development are far-reaching, but I’ll keep this tirade for another article and dive straight into the meaty part.

What’s A CRDT

CRDT stands for Conflict-Free Replicated Data Type. It’s a type of data structure that automatically resolves conflicts to ensure consistency across replicas even if updates are made concurrently, out of order (commutative), or multiple times (idempotent).

CRDTs achieve this by defining deterministic merge operations: if two users update a document at the same time, CRDTs guarantee that all replicas will eventually converge to the same state, without needing manual intervention or a central server to resolve conflicts.

Because of these properties, CRDTs are great for building distributed web systems on unreliable networks.

Basic Data Structures

Yjs provides several primary data types:

These types can be composed together to build complex collaborative documents:

import * as Y from 'yjs'

const ydoc = new Y.Doc()

ydoc.getMap('root').set('array', new Y.Array()) 

ydoc.getMap('root').get('array').push(['ok'])

All of these data structures are CRDTs so you can apply changes to them independently across clients, and they will merge without conflict.

Import / Export Updates

Yjs operates on binary updates: whenever a user modifies a shared structure, yjs generates a compact binary update that represents the change. These updates can be serialized and transmitted over any transport mechanism.

Updates can be imported/exported using two simple methods:

// export
const update = Y.encodeStateAsUpdate(doc);

// import
Y.applyUpdate(remoteDoc, update);

Networking

There are 3 main ways to share updates between clients:

Strategy 1: HTTP + Server-Sent Events (SSE)

Use HTTP requests to send / fetch updates to a central server and server-sent events to get notificed of updates.

Pros:

Cons:

This is my go-to strategy for 90% of use cases where near real-time is good enough (e.g not for competitive online games).

Strategy 2: WebSockets

Well-documented solution using good-old websockets:

import * as Y from 'yjs'

import { WebsocketProvider } from 'y-websocket'

// 1. create a new empty document

const ydoc = new Y.Doc()

// 2. connect document to websocket

const websocketProvider = new WebsocketProvider(
  'wss://demos.yjs.dev', 'count-demo', ydoc
)

// 3. the changes are automatically shared via websocket using the room id

const yarray = ydoc.getArray('count')

yarray.push([1])

Pros:

Cons:

I’m not fond of this solution because it’s missing many features like auto-reconnection or proper auth, but it’s a good way to scale concurrent edits on a single document. I’m also not a big fan of having a websocket server running 24/7 because cloud computing isn’t cheap and memory leaks are commonplace: all it takes is one bad library to crash everything.

3. WebRTC

Pros:

Cons:

I like to use WebRTC to send awareness / presence data like cursor positions or form changes. It’s a good way to decrease server costs if the room is small (20-35 participants) but a full-mesh topology gets complex fast and you’ll need to engineer something to scale beyond that so might as well use websockets.

Data Storage Layer

import * as Y from 'yjs'

import { IndexeddbPersistence } from 'y-indexeddb'

// 1. create new document

const ydoc = new Y.Doc()

// 2. Auto-sync yjs document with indexeddb document

const idb = new IndexeddbPersistence('count-demo', ydoc)

idb.whenSynced.then(() => {
    console.log('loaded data from indexed db')
})

// 3. Make changes

const yarray = ydoc.getArray('count')
yarray.push([1])

// 4. Test the changes are persisted

var ydoc2 = new Y.Doc()

const idb2 = new IndexeddbPersistence('count-demo', ydoc2)

idb2.whenSynced.then(() => {
    console.log(ydoc2.getArray('count').toJSON()) // [1]
})