Skip to main content
Technology & EngineeringLocal First471 lines

yjs-sync

Teaches building local-first collaborative applications with Yjs, the most widely adopted CRDT library for JavaScript. Covers the Y.Doc document model, shared types (Y.Map, Y.Array, Y.Text, Y.XmlFragment), the awareness protocol for presence and cursors, persistence and sync providers (WebSocket, WebRTC, IndexedDB), integrating with editors like ProseMirror/TipTap/CodeMirror/Monaco, undo/redo management, and performance optimization patterns.

Quick Summary11 lines
Yjs is a high-performance CRDT library for building collaborative, local-first applications.

## Key Points

1. **Use transactions** for batch updates to minimize observer calls and sync messages.
2. **Subdocuments** for large apps: split data into multiple Y.Docs that load on demand.
3. **Lazy loading**: don't load the entire document if the user only needs part of it.
4. **Snapshot/compact**: periodically encode and replace the full document to reduce CRDT metadata.
5. **Destroy providers** when leaving a room to free memory and close connections.
skilldb get local-first-skills/yjs-syncFull skill: 471 lines
Paste into your CLAUDE.md or agent config

Building with Yjs

Yjs is a high-performance CRDT library for building collaborative, local-first applications.


Core Concepts

Y.Doc

The root container for all shared data. Every Yjs application starts with a Y.Doc.

import * as Y from 'yjs';

const doc = new Y.Doc();

// Each doc has a unique clientID (auto-generated)
console.log(doc.clientID); // e.g., 1234567890

// All shared types are accessed by name from the doc
const myMap = doc.getMap('settings');
const myArray = doc.getArray('todos');
const myText = doc.getText('document');

Important: calling doc.getMap('settings') always returns the same instance. The name is the key.

Shared Types

Yjs provides CRDT-backed data structures that mirror JavaScript types.

// Y.Map — like a JavaScript object/Map
const settings = doc.getMap('settings');
settings.set('theme', 'dark');
settings.set('fontSize', 14);
settings.get('theme'); // 'dark'
settings.delete('fontSize');

// Y.Array — like a JavaScript array
const todos = doc.getArray('todos');
todos.push(['Buy milk', 'Walk dog']);
todos.insert(0, ['First item']);
todos.delete(1, 1); // delete 1 item at index 1
todos.get(0); // 'First item'
todos.toArray(); // ['First item', 'Walk dog']

// Y.Text — collaborative rich text
const text = doc.getText('editor');
text.insert(0, 'Hello ');
text.insert(6, 'World', { bold: true }); // with formatting
text.toString(); // 'Hello World'
text.toDelta(); // [{ insert: 'Hello ' }, { insert: 'World', attributes: { bold: true } }]

Nested Types

Shared types can be nested to build complex data structures.

const doc = new Y.Doc();
const todos = doc.getArray('todos');

// Insert a Y.Map into a Y.Array
const todo = new Y.Map();
todo.set('text', 'Buy groceries');
todo.set('done', false);
todo.set('createdAt', Date.now());
todos.push([todo]);

// Nested Y.Array inside Y.Map
const tags = new Y.Array();
tags.push(['urgent', 'personal']);
todo.set('tags', tags);

// Access nested data
const firstTodo = todos.get(0);       // Y.Map
firstTodo.get('text');                 // 'Buy groceries'
firstTodo.get('tags').toArray();       // ['urgent', 'personal']

Observing Changes

Every shared type emits events when modified, whether locally or from a remote peer.

const todos = doc.getArray('todos');

// Observe array changes
todos.observe((event) => {
  event.changes.added.forEach((item) => {
    console.log('Added:', item.content.getContent());
  });
  event.changes.deleted.forEach((item) => {
    console.log('Deleted:', item.content.getContent());
  });
});

// Deep observe: fires on nested changes too
todos.observeDeep((events) => {
  events.forEach((event) => {
    console.log('Change path:', event.path);
    console.log('Target:', event.target);
  });
});

// Y.Map observation
const settings = doc.getMap('settings');
settings.observe((event) => {
  event.keysChanged.forEach((key) => {
    console.log(`Key "${key}" changed to:`, settings.get(key));
  });
});

Transactions

Group multiple changes into a single transaction to emit one event and one sync message.

doc.transact(() => {
  const todo = new Y.Map();
  todo.set('text', 'New task');
  todo.set('done', false);
  todo.set('id', crypto.randomUUID());
  todos.push([todo]);
});
// Only one observe event fires, and one sync update is sent

Providers: Persistence and Sync

Providers connect a Y.Doc to storage or network transports. Multiple providers can be active simultaneously.

IndexedDB Provider (Offline Persistence)

import { IndexeddbPersistence } from 'y-indexeddb';

const doc = new Y.Doc();
const persistence = new IndexeddbPersistence('my-app-room', doc);

persistence.on('synced', () => {
  console.log('Loaded from IndexedDB');
});

// Data is automatically saved to IndexedDB on every change
// On next page load, data is restored from IndexedDB before network sync

WebSocket Provider (Server Relay)

import { WebsocketProvider } from 'y-websocket';

const doc = new Y.Doc();
const wsProvider = new WebsocketProvider(
  'wss://my-server.com',
  'room-name',
  doc,
  { connect: true }
);

wsProvider.on('status', ({ status }) => {
  console.log('Connection:', status); // 'connected' | 'disconnected'
});

wsProvider.on('sync', (isSynced) => {
  console.log('Synced with server:', isSynced);
});

WebRTC Provider (Peer-to-Peer)

import { WebrtcProvider } from 'y-webrtc';

const doc = new Y.Doc();
const rtcProvider = new WebrtcProvider('room-name', doc, {
  signaling: ['wss://signaling.example.com'],
  password: 'optional-encryption-key',
  maxConns: 20,
});

rtcProvider.on('peers', ({ added, removed, webrtcPeers }) => {
  console.log('Connected peers:', webrtcPeers.length);
});

Combining Providers

const doc = new Y.Doc();

// Local persistence (always active)
const idb = new IndexeddbPersistence('my-app', doc);

// Server sync (when online)
const ws = new WebsocketProvider('wss://sync.example.com', 'room', doc);

// P2P for same-network collaboration
const rtc = new WebrtcProvider('room', doc);

// All three work together:
// 1. IDB loads saved state on startup
// 2. WS syncs with server
// 3. WebRTC syncs directly with nearby peers

Awareness Protocol

Awareness shares ephemeral, per-user state like cursor positions, selections, and online status. Unlike document state, awareness is transient and not persisted.

const wsProvider = new WebsocketProvider('wss://server.com', 'room', doc);
const awareness = wsProvider.awareness;

// Set local user state
awareness.setLocalStateField('user', {
  name: 'Alice',
  color: '#ff5722',
  cursor: { index: 42 },
});

// Listen for awareness changes from all users
awareness.on('change', ({ added, updated, removed }) => {
  const states = awareness.getStates();
  states.forEach((state, clientId) => {
    if (state.user) {
      console.log(`${state.user.name} cursor at ${state.user.cursor?.index}`);
    }
  });
});

// Update cursor position as user types
editor.on('cursorChange', (position) => {
  awareness.setLocalStateField('user', {
    ...awareness.getLocalState().user,
    cursor: position,
  });
});

Editor Integrations

TipTap (ProseMirror)

import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';

const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://server.com', 'doc-1', doc);

const editor = new Editor({
  extensions: [
    StarterKit.configure({ history: false }), // disable built-in history
    Collaboration.configure({
      document: doc,
      field: 'content', // name of the Y.XmlFragment
    }),
    CollaborationCursor.configure({
      provider,
      user: { name: 'Alice', color: '#ff5722' },
    }),
  ],
});

CodeMirror 6

import { EditorState } from '@codemirror/state';
import { EditorView, basicSetup } from 'codemirror';
import { yCollab } from 'y-codemirror.next';

const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://server.com', 'code-1', doc);
const yText = doc.getText('source');

const state = EditorState.create({
  doc: yText.toString(),
  extensions: [
    basicSetup,
    yCollab(yText, provider.awareness),
  ],
});

const view = new EditorView({ state, parent: document.getElementById('editor') });

Monaco Editor

import { MonacoBinding } from 'y-monaco';

const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://server.com', 'code-1', doc);
const yText = doc.getText('source');

const editor = monaco.editor.create(document.getElementById('editor'), {
  value: '',
  language: 'javascript',
});

const binding = new MonacoBinding(
  yText,
  editor.getModel(),
  new Set([editor]),
  provider.awareness
);

Undo / Redo

Yjs provides an UndoManager that tracks changes per user (only undoes your own edits in a collaborative document).

const doc = new Y.Doc();
const text = doc.getText('content');

const undoManager = new Y.UndoManager(text, {
  trackedOrigins: new Set([null]), // track direct edits
  captureTimeout: 500, // group edits within 500ms
});

// Make edits
text.insert(0, 'Hello World');

// Undo
undoManager.undo(); // text is now empty

// Redo
undoManager.redo(); // text is 'Hello World' again

// Track multiple shared types
const multiUndo = new Y.UndoManager([text, doc.getArray('items')]);

// Listen for undo/redo stack changes
undoManager.on('stack-item-added', (event) => {
  console.log('Can undo:', undoManager.canUndo());
  console.log('Can redo:', undoManager.canRedo());
});

Encoding and Sync Protocol

State Vectors and Updates

// Encode the full document state
const fullState = Y.encodeStateAsUpdate(doc);
// Save to file, send over network, etc.

// Apply a remote update
Y.applyUpdate(doc, remoteUpdate);

// Efficient sync: only send what the other side is missing
const localStateVector = Y.encodeStateVector(doc);
// Send localStateVector to remote, remote responds with:
const diff = Y.encodeStateAsUpdate(remoteDoc, localStateVector);
// Apply the diff
Y.applyUpdate(doc, diff);

Merging Documents

// Merge two docs that diverged
const docA = new Y.Doc();
const docB = new Y.Doc();

// ... both docs edited independently ...

// Merge B into A
const updateB = Y.encodeStateAsUpdate(docB);
Y.applyUpdate(docA, updateB);

// Merge A into B
const updateA = Y.encodeStateAsUpdate(docA);
Y.applyUpdate(docB, updateA);

// Both docs are now identical

Performance Tips

  1. Use transactions for batch updates to minimize observer calls and sync messages.
  2. Subdocuments for large apps: split data into multiple Y.Docs that load on demand.
  3. Lazy loading: don't load the entire document if the user only needs part of it.
  4. Snapshot/compact: periodically encode and replace the full document to reduce CRDT metadata.
  5. Destroy providers when leaving a room to free memory and close connections.
// Subdocuments
const rootDoc = new Y.Doc();
const projectIds = rootDoc.getArray('projectIds');

// Each project is a separate Y.Doc, loaded on demand
function loadProject(projectId) {
  const projectDoc = new Y.Doc({ guid: projectId });
  new IndexeddbPersistence(projectId, projectDoc);
  new WebsocketProvider('wss://server.com', projectId, projectDoc);
  return projectDoc;
}

// Cleanup
function unloadProject(projectDoc, providers) {
  providers.forEach((p) => p.destroy());
  projectDoc.destroy();
}

Server: y-websocket

Run the standard Yjs WebSocket server for relay-based sync.

# Install
npm install y-websocket

# Run server (stores docs in memory by default)
npx y-websocket

# With LevelDB persistence
HOST=0.0.0.0 PORT=1234 YPERSISTENCE=./yjs-data npx y-websocket

Custom server with authentication:

import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';

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

wss.on('connection', (ws, req) => {
  const token = new URL(req.url, 'http://localhost').searchParams.get('token');
  if (!verifyToken(token)) {
    ws.close(4401, 'Unauthorized');
    return;
  }
  setupWSConnection(ws, req);
});

Install this skill directly: skilldb add local-first-skills

Get CLI access →

Related Skills

crdt-fundamentals

Teaches Conflict-free Replicated Data Types (CRDTs), the mathematical foundation for local-first sync. Covers how CRDTs guarantee eventual consistency without coordination, the difference between state-based and operation-based CRDTs, and practical implementations of G-Counter, PN-Counter, LWW-Register, OR-Set, G-Set, and RGA (Replicated Growable Array). Includes causal ordering, vector clocks, and guidance on choosing the right CRDT for your data model.

Local First454L

electric-sql

Teaches ElectricSQL, a Postgres-backed local-first sync framework. Covers the Electric architecture where Postgres is the source of truth and data syncs to local SQLite databases on client devices via shape-based partial replication. Includes shape definitions, live queries, offline-first patterns, conflict resolution with rich CRDTs, integration with React and Expo (React Native), deployment patterns, and migration strategies.

Local First433L

indexeddb-patterns

Teaches IndexedDB patterns for local-first web applications, using Dexie.js as the primary wrapper library. Covers schema design and versioning, creating indexes for efficient queries, transaction patterns, performance optimization (bulk operations, pagination, lazy loading), migration strategies for schema evolution, storage quota management, data export and import, and integration patterns with sync engines and reactive frameworks.

Local First556L

local-first-auth

Teaches authentication and authorization patterns for local-first applications that must work offline. Covers offline-capable auth with cached tokens, permission sync and local enforcement, encrypted local storage for sensitive data, key management with device-bound keys, device authorization and revocation, multi-device identity linking, end-to-end encryption for synced data, and secure patterns for handling auth in disconnected environments.

Local First606L

local-first-fundamentals

Teaches the local-first software paradigm where applications store data on the user's device, work fully offline, and sync to peers or servers when connectivity is available. Covers the spectrum from cloud-first to offline-first to local-first, core benefits (instant UX, offline capability, data ownership, privacy), key challenges (conflict resolution, sync complexity, storage limits), architectural patterns, and decision frameworks for when local-first is the right choice.

Local First285L

sync-engine-architecture

Teaches how to design and build a sync engine for local-first applications. Covers the operation log as the foundation, conflict resolution strategies (last-write-wins, operational transform, CRDTs), server reconciliation patterns, partial sync for large datasets, bandwidth optimization techniques, version vectors and causal consistency, clock synchronization, and practical implementation patterns with code examples.

Local First572L