Yjs Collaborative Editor Architecture: What I Learned Building Awareness, Offline Persistence, and Server Relay from Scratch
An editor where multiple people can edit simultaneously and changes merge naturally — like Google Docs. Honestly, when I first heard I had to build this, I was overwhelmed. I'd done real-time sync with WebSockets before, but how do you handle conflicts? What happens when someone edits offline and reconnects? How do you share cursors? As I dug in one piece at a time, I found a library called Yjs that wraps all of this into a clean set of layers. When I discovered that Yjs already powers the sync in Jupyter, Tiptap, and Excalidraw, I thought, "This isn't just popular for no reason."
This post is aimed at frontend and full-stack developers who have experience building real-time WebSocket features. We'll walk through the three core layers you actually run into when building a collaborative editor with Yjs — Awareness, offline persistence, and server relay design — in order. By the end, you'll understand each layer's role and how they fit together, and you'll be able to make informed decisions about what to choose, from a small PoC all the way to a production system that needs horizontal scaling.
TL;DR: Yjs handles conflict resolution at the library level using CRDTs. Getting started takes just two lines —
npx y-websocketandy-indexeddb— and when you're ready for production, you switch to a Hocuspocus + Redis setup. Awareness is exclusively for ephemeral state like cursors and presence; it manages itself per peer with no persistent storage.
Core Concepts
How Yjs Resolves Conflicts: CRDTs and YATA
Yjs is a CRDT (Conflict-free Replicated Data Type)-based library. The core idea behind CRDTs is simple: design your data structure so that the final state always converges to the same result, regardless of the order updates arrive in. There's no need for a server to arbitrate "this is the real latest version."
What is a CRDT? A data structure designed for distributed environments where multiple nodes modify data simultaneously — it automatically converges to a consistent final state without network delays or ordering issues causing divergence. The conflict resolution logic is built into the data structure itself.
Internally, Yjs uses the YATA (Yet Another Transformation Approach) algorithm. Presented in a paper by Kevin Jahns as an independent algorithm compared and contrasted against prior work like RGA and WOOT, it's optimized for scenarios with frequent sequential insertions, like typing text. In early benchmarks on text-insertion-heavy scenarios, it outperformed Automerge by a wide margin; though Automerge v2 has significantly closed the gap with its WASM-based implementation, Yjs still tends to come out ahead.
When I first encountered these concepts, my immediate thought was "okay, but how does this actually work in code?" — thankfully, the actual API is far simpler than the theory. Let's look at the code that connects all three layers together, then break down each concept.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
// Local first, server second
const indexeddbProvider = new IndexeddbPersistence('doc-room-1', ydoc)
const wsProvider = new WebsocketProvider('wss://relay.example.com', 'doc-room-1', ydoc)
wsProvider.awareness.setLocalState({
user: { name: 'Alice', color: '#ff6b6b' },
})Three lines to attach offline persistence, real-time sync, and Awareness all at once. Each layer shares a single Y.Doc while operating independently.
The Three Layers of a Collaborative Editor
| Layer | Role | Persisted |
|---|---|---|
| Awareness | Sharing ephemeral state: who's connected, where cursors are, etc. | ❌ No persistent storage needed |
| Offline Persistence | Storing document snapshots locally, syncing only diffs on reconnect | ✅ IndexedDB, etc. |
| Server Relay | Broadcasting updates to all connected clients | Optional |
The elegance of Yjs's architecture is that these three layers operate independently while complementing each other. You can even do P2P sync without a server using WebRTC — useful for small internal team tools where you want browsers to communicate directly without central infrastructure. That said, P2P can run into connection reliability issues in firewalled environments or on mobile, so a server relay is more practical for most production setups.
Practical Application
Example 1: Basic Setup — Connecting All Three Layers at Once
When I first set up Yjs, the most confusing part was how to attach multiple providers. It turns out to be simple: just attach as many providers as you want to a single Y.Doc.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
// 1. Offline persistence — load locally first
const indexeddbProvider = new IndexeddbPersistence('doc-room-1', ydoc)
indexeddbProvider.on('synced', () => {
console.log('Local data loaded, now connecting to server')
})
// 2. Network sync — sends only diffs
const wsProvider = new WebsocketProvider(
'wss://relay.example.com',
'doc-room-1',
ydoc
)
// 3. Awareness — share cursor position and user info
const awareness = wsProvider.awareness
awareness.setLocalState({
user: {
name: 'Alice',
color: '#ff6b6b',
},
})
// Subscribe to other users' cursor changes
awareness.on('change', ({ added, updated, removed }) => {
const states = Array.from(awareness.getStates().entries())
// UI rendering depends on your editor integration — see Example 2
renderCursors(states)
})| Code section | Description |
|---|---|
IndexeddbPersistence |
Stores the document in the browser's IndexedDB. Automatically merges after offline editing upon reconnect |
WebsocketProvider |
WebSocket connection to the server. Sends local changes to the server and applies updates received from the server to the document |
awareness.setLocalState |
Ephemeral state you can only write to your own slot. Not permanently saved to the server |
awareness.on('change') |
Subscribes to state changes from other peers (joining, leaving, cursor movement) |
The order matters: initialize IndexeddbPersistence before the WebSocket. Local data must be loaded first before exchanging diffs with the server — this makes the initial load faster and smoother. Do it the other way around and you risk an empty document state being pushed up to the server. I actually reversed this order once in a real project and nearly wiped a document.
Example 2: Implementing Multi-Cursor with Awareness
The Awareness CRDT assigns each peer a unique slot. You can only write to your own slot; other peers' slots are read-only. This design lets you safely share multiple users' cursor positions without worrying about race conditions.
One important caveat: if you store cursor positions as absolute indices (anchor: 10, head: 15), text changes will shift those indices and cause cursors to jump to wrong positions. You need to use Yjs's relative position API instead.
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
// When integrating with Tiptap — the binding handles relative positions for you
const editor = new Editor({
extensions: [
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider: wsProvider,
user: {
name: 'Alice',
color: '#ff6b6b',
},
}),
],
})
// If you want to control Awareness state directly, use relative positions
import * as Y from 'yjs'
function updateCursor(editor) {
const { from, to } = editor.state.selection
const ytext = ydoc.getText('content')
// Store as relative position, not absolute index
const anchor = Y.createRelativePositionFromTypeIndex(ytext, from)
const head = Y.createRelativePositionFromTypeIndex(ytext, to)
wsProvider.awareness.setLocalState({
user: { name: 'Alice', color: '#ff6b6b' },
cursor: { anchor, head },
})
}
// Clean up on component unmount
function cleanup() {
wsProvider.awareness.setLocalState(null) // signal departure
wsProvider.destroy()
indexeddbProvider.destroy()
}What "ephemeral data" means: Awareness state is automatically removed when a peer disconnects. You don't need to build a separate "someone left" event — it's automatically included in the
removedarray ofawareness.on('change').
Example 3: Server Relay Design — When to Move from y-websocket to Hocuspocus
Starting with y-websocket is extremely simple to set up. The problem comes as traffic grows. The server holds document state in memory, so restarting it wipes everything — even for a small service, deploying this to production as-is is risky.
// y-websocket server (for development / small scale)
// Run instantly with: npx y-websocket
// Single node, in-memory — document state lost on server restart
// Hocuspocus server (with built-in auth hooks, Redis scaling)
import { Server } from '@hocuspocus/server'
import { Redis } from '@hocuspocus/extension-redis'
import { Database } from '@hocuspocus/extension-database'
const server = Server.configure({
port: 1234,
extensions: [
new Redis({
host: 'redis-host',
port: 6379,
}),
new Database({
fetch: async ({ documentName }) => {
return await db.getDocument(documentName)
},
store: async ({ documentName, state }) => {
await db.saveDocument(documentName, state)
},
}),
],
async onAuthenticate(data) {
const { token } = data
const user = await verifyToken(token)
if (!user) throw new Error('Unauthorized')
return { user }
},
})
server.listen()When horizontally scaling, sticky session configuration is required. WebSockets hold connection state in server memory, so if a load balancer routes the same client's requests to different servers each time, Awareness state sync breaks down. Even with Redis attached, you still need to configure this routing separately.
| Scale | Recommended Solution | Characteristics |
|---|---|---|
| Dev / PoC | y-websocket |
Single npx command, in-memory |
| Small / Medium | Hocuspocus |
Auth hooks, DB integration, Redis extension built in |
| Large-scale horizontal | y-redis or Hocuspocus + Redis |
Redis Streams-based, sticky sessions required |
| No infrastructure | Liveblocks, PartyKit, Y-Sweet | Managed services, edge-distributed |
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Automatic conflict resolution | Final state converges regardless of update application order. No need to write conflict handling code yourself |
| Offline support | One line of y-indexeddb for offline editing with automatic merging |
| Transport-layer independence | Works over WebSocket, WebRTC, HTTP. Just swap the provider |
| Performance | One of the fastest CRDT implementations for text-insertion-heavy scenarios |
| Ecosystem | Bindings provided for major editors: ProseMirror, CodeMirror 6, Monaco, Quill, and more |
| No central server required | Fully P2P configuration possible in WebRTC mode |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Monotonically growing document size | Deleted text remains as tombstones | Periodic snapshots + garbage collection (ydoc.gc = true) |
| Rich text limitations | Potential semantic loss during complex format transformations | Editor bindings (like Tiptap) handle most of this. Custom formats need care |
| No offline presence | Cursors and Awareness can't propagate while offline | Explicitly indicate offline status in the UI |
| y-websocket single-node limitation | In-memory state, no horizontal scaling | Switch to Hocuspocus + Redis or y-redis |
| Learning curve | Using it without understanding CRDTs leads to unexpected merges | Recommended to read the official CRDT overview first |
What is a tombstone? In CRDTs, instead of actually removing a deleted element, it's marked as "deleted" and left in place. This is necessary for conflict-free merging with other peers, but has the side effect of making the document grow indefinitely. You can enable garbage collection with
ydoc.gc = true(the default), but it's important to use this alongside snapshots. If gc is enabled while loading only old updates without a snapshot, document data can become corrupted.
Most Common Mistakes in Practice
-
Initializing
IndexeddbPersistenceafter the WebSocket — If the server connection opens before local data is loaded, an empty document state can get pushed to the server. It's recommended to confirm local-first loading afterindexeddbProvider.on('synced'), or by initializing both simultaneously with local-first loading verified. -
Using y-websocket as-is in production — It's perfect for development, but restarting the server wipes all in-memory document state. Even for small services, Hocuspocus + DB integration is essential.
-
Not calling
awareness.setLocalState(null)on component unmount — Skip this and ghost cursors will linger on screen. From other users' perspective, they'll see the cursor of someone who already left — an unpleasant experience. It's a situation that comes up frequently in practice, and the cause can be surprisingly hard to track down before you know to look for it.
Closing Thoughts
Ultimately, the biggest thing I took away from working with Yjs was that the hardest parts of building a collaborative editor are already handled inside the library. If I'd tried to implement conflict resolution from scratch, it would have taken months — thanks to Yjs, I was able to spend that time on actual product logic. The key design insight is that the three layers — Awareness, offline persistence, and server relay — each operate independently while connecting naturally through a single Y.Doc.
Three steps you can try right now:
- Run a local demo — Spin up a relay server with
npx y-websocket, then open the Tiptap example from the Yjs official demo in two tabs. You can see multi-cursor behavior in action in under 5 minutes, no installation required. - Add
y-indexeddband verify offline behavior — Addnew IndexeddbPersistence('my-doc', ydoc)to the demo, then switch your browser to offline mode and make some edits. Watching the content automatically merge after reconnecting gives you an intuitive feel for how CRDTs work. - Switch your server to Hocuspocus and add an auth hook — Set up a server with the
@hocuspocus/serverpackage, then add JWT verification to theonAuthenticatehook. Once you've done this, you'll have a foundation ready to deploy to production.
References
- Yjs Official Docs - Introduction | yjs.dev
- Yjs Official Docs - Awareness & Presence | yjs.dev
- Yjs Official Docs - Offline Support (y-indexeddb) | yjs.dev
- Yjs Official Docs - y-websocket | yjs.dev
- GitHub: yjs/y-indexeddb
- GitHub: ueberdosis/hocuspocus
- Hocuspocus Scalability Guide | tiptap.dev
- y-redis: Discussion on an alternative backend to y-websocket | discuss.yjs.dev
- Are CRDTs suitable for shared editing? | Kevin Jahns
- ElectricSQL: AI Agents as CRDT Peers (2026) | electric.ax
- GitHub: electric-sql/collaborative-ai-editor
- Liveblocks Yjs Introduction | liveblocks.io
- PartyKit y-partykit API Docs | partykit.io
- Tiptap Awareness Docs | tiptap.dev
- Tutorial: Collaborative Editor with Yjs + valtio + React | DEV Community