DEV Community

Wilson Xu
Wilson Xu

Posted on

Building Real-Time Collaborative Apps with CRDTs and Y.js

Building Real-Time Collaborative Apps with CRDTs and Y.js

Real-time collaboration has become table stakes for modern applications. Users expect to see each other's cursors, watch edits appear instantly, and never lose work to a merge conflict. Figma, Notion, Google Docs, and Linear all deliver this experience seamlessly. The technology powering most of these applications? CRDTs — Conflict-free Replicated Data Types.

If you've ever tried building collaborative features with naive approaches like last-write-wins or operational transformation (OT), you know the pain. Race conditions, lost updates, and complex server-side logic make it a nightmare. CRDTs eliminate an entire class of these problems by making conflicts mathematically impossible.

In this article, we'll go from zero to a working collaborative application. You'll learn what CRDTs are, how Y.js implements them for JavaScript, and how to build both a collaborative text editor and a shared whiteboard with real code. We'll also cover persistence, awareness (showing other users' cursors), and performance at scale.

What Are CRDTs?

A CRDT is a data structure that can be replicated across multiple peers, updated independently and concurrently, and merged automatically without conflicts. The "conflict-free" part is the key insight: the data structure is designed so that any order of operations always converges to the same final state.

Think of it this way. Two users are editing the same document. User A inserts "Hello" at position 0. User B inserts "World" at position 0. With a naive approach, one edit overwrites the other. With a CRDT, both edits are preserved, and both users end up seeing the same result — regardless of network latency or the order messages arrive.

There are two main families of CRDTs:

State-based CRDTs (CvRDTs) send the entire state to other peers. The merge function combines two states into one. This is simple but bandwidth-heavy.

Operation-based CRDTs (CmRDTs) send only the operations (insertions, deletions) to peers. This is more efficient but requires a reliable delivery layer.

Y.js uses a hybrid approach — it encodes operations efficiently and can sync via state vectors (compact representations of what each peer has seen). This gives you the best of both worlds: small payloads and reliable convergence.

The mathematical property that makes CRDTs work is called commutativity — the order in which operations are applied doesn't matter. Insert A then insert B gives the same result as insert B then insert A. This is what eliminates the need for a central authority to resolve conflicts.

Y.js: The CRDT Library for JavaScript

Y.js is the most widely adopted CRDT implementation for JavaScript. It's used in production by companies like Jupyter, Cargo, and hundreds of collaborative applications. Here's why it stands out:

  • Shared types: Y.js provides Y.Text, Y.Array, Y.Map, and Y.XmlFragment — CRDT-powered equivalents of strings, arrays, objects, and DOM trees.
  • Provider-agnostic: Sync over WebSocket, WebRTC, or any custom transport.
  • Editor bindings: First-class integrations with TipTap, Quill, ProseMirror, Monaco, and CodeMirror.
  • Sub-millisecond merges: Y.js can merge thousands of concurrent operations in under a millisecond.
  • Small documents: A Y.js document encoding 100,000 characters of collaborative text is typically under 200KB.

Let's start by installing the core packages:

npm install yjs y-websocket y-prosemirror @tiptap/core @tiptap/starter-kit @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor
Enter fullscreen mode Exit fullscreen mode

A Y.js document (Y.Doc) is the container for all shared data. Every peer creates their own Y.Doc instance and syncs it with others through providers:

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'

// Create a new Y.js document
const ydoc = new Y.Doc()

// Connect to a WebSocket server for syncing
const provider = new WebsocketProvider(
  'wss://your-server.com',
  'my-document-room',  // room name — all peers in the same room sync
  ydoc
)

// Get a shared text type
const ytext = ydoc.getText('editor-content')

// Observe changes
ytext.observe(event => {
  console.log('Text changed:', ytext.toString())
})

// Make an edit — this propagates to all connected peers
ytext.insert(0, 'Hello, collaborative world!')
Enter fullscreen mode Exit fullscreen mode

The WebsocketProvider handles connection management, reconnection, and message encoding automatically. You point it at a y-websocket server (which you can run with a single command), give it a room name, and it syncs your document with every other peer in that room.

Building a Collaborative Text Editor

Let's build a fully functional collaborative text editor with React, TipTap (a headless editor built on ProseMirror), and Y.js. This is production-grade code you can drop into a real application.

Setting Up the Y.js Provider

First, create a hook that manages the Y.js document and WebSocket connection:

// useCollaboration.ts
import { useEffect, useMemo } from 'react'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'

interface UseCollaborationOptions {
  roomName: string
  username: string
  color: string
}

export function useCollaboration({ roomName, username, color }: UseCollaborationOptions) {
  const ydoc = useMemo(() => new Y.Doc(), [])

  const provider = useMemo(() => {
    return new WebsocketProvider(
      'wss://your-yjs-server.com',
      roomName,
      ydoc,
      { connect: true }
    )
  }, [ydoc, roomName])

  // Set awareness state — this lets other users see your cursor
  useEffect(() => {
    provider.awareness.setLocalStateField('user', {
      name: username,
      color: color,
    })

    return () => {
      provider.disconnect()
      ydoc.destroy()
    }
  }, [provider, ydoc, username, color])

  return { ydoc, provider }
}
Enter fullscreen mode Exit fullscreen mode

Integrating with TipTap

Now wire the Y.js document into a TipTap editor with collaboration and cursor awareness:

// CollaborativeEditor.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import { useCollaboration } from './useCollaboration'

interface Props {
  roomName: string
  username: string
}

export function CollaborativeEditor({ roomName, username }: Props) {
  const userColor = `#${Math.floor(Math.random() * 16777215).toString(16)}`
  const { ydoc, provider } = useCollaboration({
    roomName,
    username,
    color: userColor,
  })

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        // Disable the default history — Y.js provides its own undo manager
        history: false,
      }),
      Collaboration.configure({
        document: ydoc,
        field: 'content', // name of the shared Y.XmlFragment
      }),
      CollaborationCursor.configure({
        provider: provider,
        user: {
          name: username,
          color: userColor,
        },
      }),
    ],
  })

  return (
    <div className="editor-wrapper">
      <div className="connection-status">
        {provider.wsconnected ? 'Connected' : 'Reconnecting...'}
      </div>
      <EditorContent editor={editor} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

That's it. With under 60 lines of code, you have a collaborative text editor where multiple users can type simultaneously, see each other's cursors in real-time, and never experience a conflict.

Handling Awareness

Awareness is the protocol Y.js uses to share ephemeral state between peers — things like cursor positions, user names, selection ranges, and online status. Unlike the document state, awareness data is not persisted. It's strictly real-time presence information.

// Listen for awareness changes
provider.awareness.on('change', () => {
  const states = provider.awareness.getStates()

  states.forEach((state, clientId) => {
    if (clientId !== ydoc.clientID) {
      console.log(`User ${state.user?.name} is at cursor position:`, state.cursor)
    }
  })
})

// Update your own awareness state
provider.awareness.setLocalStateField('cursor', {
  anchor: 42,
  head: 42,
})

// Detect when users go offline
provider.awareness.on('change', ({ removed }) => {
  removed.forEach(clientId => {
    console.log(`Client ${clientId} went offline`)
  })
})
Enter fullscreen mode Exit fullscreen mode

TipTap's CollaborationCursor extension handles all of this automatically — it renders colored cursors with name labels for every connected user. But if you're building a custom integration, the awareness API gives you full control.

Running the WebSocket Server

Y.js needs a signaling server to relay messages between peers. The y-websocket package includes a production-ready server:

# Run the server
npx y-websocket --port 1234

# Or programmatically
Enter fullscreen mode Exit fullscreen mode
// server.js
import { createServer } from 'http'
import { WebSocketServer } from 'ws'
import { setupWSConnection } from 'y-websocket/bin/utils'

const server = createServer()
const wss = new WebSocketServer({ server })

wss.on('connection', (ws, req) => {
  setupWSConnection(ws, req, {
    // Enable persistence (see section below)
    docName: req.url.slice(1),
  })
})

server.listen(1234, () => {
  console.log('Y.js WebSocket server running on port 1234')
})
Enter fullscreen mode Exit fullscreen mode

Building a Collaborative Whiteboard

CRDTs aren't limited to text. Let's build a shared whiteboard where multiple users can draw simultaneously. We'll use Y.Array to store strokes and Y.Map for each stroke's properties.

// useWhiteboard.ts
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { useEffect, useRef, useMemo, useCallback } from 'react'

interface Point { x: number; y: number }

interface Stroke {
  points: Point[]
  color: string
  width: number
  userId: string
}

export function useWhiteboard(roomName: string, userId: string) {
  const ydoc = useMemo(() => new Y.Doc(), [])
  const provider = useMemo(
    () => new WebsocketProvider('wss://your-server.com', roomName, ydoc),
    [ydoc, roomName]
  )
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const ystrokes = ydoc.getArray<Y.Map<any>>('strokes')

  const drawAllStrokes = useCallback(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext('2d')!
    ctx.clearRect(0, 0, canvas.width, canvas.height)

    ystrokes.forEach((ystroke: Y.Map<any>) => {
      const points = ystroke.get('points') as Point[]
      const color = ystroke.get('color') as string
      const width = ystroke.get('width') as number

      if (points.length < 2) return

      ctx.beginPath()
      ctx.strokeStyle = color
      ctx.lineWidth = width
      ctx.lineCap = 'round'
      ctx.lineJoin = 'round'
      ctx.moveTo(points[0].x, points[0].y)

      for (let i = 1; i < points.length; i++) {
        ctx.lineTo(points[i].x, points[i].y)
      }
      ctx.stroke()
    })
  }, [ystrokes])

  // Redraw whenever strokes change (local or remote)
  useEffect(() => {
    const observer = () => drawAllStrokes()
    ystrokes.observeDeep(observer)
    return () => ystrokes.unobserveDeep(observer)
  }, [ystrokes, drawAllStrokes])

  const startStroke = useCallback((color: string, width: number): Y.Map<any> => {
    const ystroke = new Y.Map()
    ydoc.transact(() => {
      ystroke.set('points', [])
      ystroke.set('color', color)
      ystroke.set('width', width)
      ystroke.set('userId', userId)
      ystrokes.push([ystroke])
    })
    return ystroke
  }, [ydoc, ystrokes, userId])

  const addPoint = useCallback((ystroke: Y.Map<any>, point: Point) => {
    const points = ystroke.get('points') as Point[]
    ystroke.set('points', [...points, point])
  }, [])

  return { canvasRef, startStroke, addPoint, provider }
}
Enter fullscreen mode Exit fullscreen mode

And the React component that puts it all together:

// Whiteboard.tsx
import { useRef, useState } from 'react'
import { useWhiteboard } from './useWhiteboard'
import * as Y from 'yjs'

export function Whiteboard({ roomName, userId }: { roomName: string; userId: string }) {
  const { canvasRef, startStroke, addPoint, provider } = useWhiteboard(roomName, userId)
  const [isDrawing, setIsDrawing] = useState(false)
  const currentStroke = useRef<Y.Map<any> | null>(null)

  const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
    setIsDrawing(true)
    const rect = canvasRef.current!.getBoundingClientRect()
    const point = { x: e.clientX - rect.left, y: e.clientY - rect.top }
    currentStroke.current = startStroke('#000000', 3)
    addPoint(currentStroke.current, point)
  }

  const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
    if (!isDrawing || !currentStroke.current) return
    const rect = canvasRef.current!.getBoundingClientRect()
    const point = { x: e.clientX - rect.left, y: e.clientY - rect.top }
    addPoint(currentStroke.current, point)
  }

  const handleMouseUp = () => {
    setIsDrawing(false)
    currentStroke.current = null
  }

  return (
    <canvas
      ref={canvasRef}
      width={1200}
      height={800}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseUp}
      style={{ border: '1px solid #ccc', cursor: 'crosshair' }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Every stroke drawn by any user appears on every other user's canvas in real-time. Because Y.js handles the CRDT synchronization, strokes never conflict — even if two users draw at the exact same millisecond.

Persistence: Saving CRDT State to a Database

In production, you need to persist the document state so it survives server restarts and can be loaded by users who join later. Y.js makes this straightforward with its encoding API.

Encoding and Decoding Documents

import * as Y from 'yjs'

// Encode the entire document state to a Uint8Array
const state = Y.encodeStateAsUpdate(ydoc)

// Save to your database (e.g., PostgreSQL, S3, Redis)
await db.query(
  'INSERT INTO documents (room_name, state) VALUES ($1, $2) ON CONFLICT (room_name) DO UPDATE SET state = $2',
  [roomName, Buffer.from(state)]
)

// Later, restore the document
const savedState = await db.query('SELECT state FROM documents WHERE room_name = $1', [roomName])
if (savedState.rows.length > 0) {
  const restoredDoc = new Y.Doc()
  Y.applyUpdate(restoredDoc, new Uint8Array(savedState.rows[0].state))
}
Enter fullscreen mode Exit fullscreen mode

Incremental Updates

For large documents, encoding the entire state on every change is wasteful. Y.js supports incremental updates:

// Listen for incremental updates
ydoc.on('update', (update: Uint8Array, origin: any) => {
  // Append this small update to your write-ahead log
  await appendToLog(roomName, update)
})

// To reconstruct, apply all updates in sequence
const updates = await getUpdatesFromLog(roomName)
const ydoc = new Y.Doc()
updates.forEach(update => Y.applyUpdate(ydoc, update))

// Periodically compact by saving a full snapshot
// and clearing the log
const snapshot = Y.encodeStateAsUpdate(ydoc)
await saveSnapshot(roomName, snapshot)
await clearLog(roomName)
Enter fullscreen mode Exit fullscreen mode

Server-Side Persistence with y-websocket

The y-websocket server supports pluggable persistence out of the box:

// server.js with LevelDB persistence
import { LeveldbPersistence } from 'y-leveldb'

const persistence = new LeveldbPersistence('./yjs-docs')

const server = createServer()
const wss = new WebSocketServer({ server })

wss.on('connection', (ws, req) => {
  const docName = req.url.slice(1)

  setupWSConnection(ws, req, {
    docName,
    // Automatically persists and restores documents
    persistence: {
      provider: persistence,
      bindState: async (docName, ydoc) => {
        const stored = await persistence.getYDoc(docName)
        const stateVector = Y.encodeStateAsUpdate(stored)
        Y.applyUpdate(ydoc, stateVector)
        ydoc.on('update', update => {
          persistence.storeUpdate(docName, update)
        })
      },
      writeState: async (docName, ydoc) => {
        // Called when all connections to a document are closed
        await persistence.storeUpdate(docName, Y.encodeStateAsUpdate(ydoc))
      }
    }
  })
})
Enter fullscreen mode Exit fullscreen mode

Performance Considerations at Scale

Y.js is fast, but at scale you need to think about a few things.

Document Size

Every CRDT carries metadata — tombstones for deleted content, unique identifiers for each operation. A document with heavy editing history can grow larger than the visible content. Y.js mitigates this with efficient binary encoding, but for very large documents (millions of operations), you should periodically compact:

// Garbage collection — remove tombstones from deleted content
// Y.js does this automatically when encoding state
const compacted = Y.encodeStateAsUpdate(ydoc)
const freshDoc = new Y.Doc()
Y.applyUpdate(freshDoc, compacted)
// freshDoc is now a compacted version without unnecessary tombstones
Enter fullscreen mode Exit fullscreen mode

Network Bandwidth

Each keystroke generates a Y.js update message. For 100 concurrent users typing at 5 characters per second, that's 500 messages per second per document. Strategies to manage this:

  • Batch updates: Buffer changes and send them every 50-100ms instead of on every keystroke.
  • State vectors: When a peer reconnects, Y.js uses state vectors to send only the missing updates instead of the full document.
  • WebRTC for peer-to-peer: For low-latency scenarios, use y-webrtc to sync directly between peers without routing through a server.
import { WebrtcProvider } from 'y-webrtc'

// Peers sync directly — the signaling server only facilitates initial connection
const provider = new WebrtcProvider('my-room', ydoc, {
  signaling: ['wss://your-signaling-server.com'],
})
Enter fullscreen mode Exit fullscreen mode

Server Architecture

For a single document, one y-websocket server handles thousands of concurrent connections. For multiple documents across a fleet:

  • Sticky sessions: Route all connections for a given document to the same server. This avoids cross-server sync complexity.
  • Horizontal scaling: Use Redis pub/sub or NATS to relay Y.js updates between servers when sticky sessions aren't possible.
  • Cold storage offloading: Move inactive documents to S3 or a database. Load them back into memory when a user opens them.
// Redis-based cross-server sync
import Redis from 'ioredis'

const pub = new Redis()
const sub = new Redis()

sub.subscribe(`doc:${roomName}`)
sub.on('message', (channel, message) => {
  const update = Buffer.from(message, 'base64')
  Y.applyUpdate(ydoc, new Uint8Array(update))
})

ydoc.on('update', (update) => {
  pub.publish(`doc:${roomName}`, Buffer.from(update).toString('base64'))
})
Enter fullscreen mode Exit fullscreen mode

Undo/Redo

Y.js includes an UndoManager that provides per-user undo/redo — pressing Ctrl+Z undoes only your changes, not someone else's:

import { UndoManager } from 'yjs'

const ytext = ydoc.getText('content')
const undoManager = new UndoManager(ytext, {
  trackedOrigins: new Set([ydoc.clientID]),
})

// Undo last local change
undoManager.undo()

// Redo
undoManager.redo()
Enter fullscreen mode Exit fullscreen mode

Conclusion

CRDTs have moved from academic research to production infrastructure. Y.js makes this technology accessible to any JavaScript developer — you get conflict-free collaboration with minimal server-side complexity.

Here's what we covered:

  • CRDTs guarantee convergence without a central authority. Any order of operations produces the same result.
  • Y.js provides battle-tested shared types (Y.Text, Y.Array, Y.Map) that sync automatically across peers.
  • Building a collaborative editor takes under 60 lines of code with TipTap and the Collaboration extension.
  • Shared whiteboards work by storing strokes in a Y.Array of Y.Map objects.
  • Persistence is handled by encoding document state to binary and storing it in any database.
  • Scaling requires attention to document size, network batching, and server architecture, but Y.js is built for it.

The next time you need multi-user editing, shared canvases, collaborative forms, or any feature where multiple users modify the same data simultaneously, reach for Y.js. The days of building fragile WebSocket sync from scratch are over.

Start with the Y.js documentation, the TipTap collaboration guide, and the y-websocket server for a production-ready signaling layer. The ecosystem is mature, the performance is proven, and the developer experience is excellent.

Top comments (0)