Beyond Last-Write-Wins: Scaling Collaborative State with WebSockets and CRDTs
Stop losing user data to race conditions. I've spent the last three years building production collaborative tools; here is why WebSockets alone aren't enough and how CRDTs like Yjs solve the state problem.

You've just pushed a WebSocket-based real-time editor to production, only to watch your database consistency crumble as two users type in the same field simultaneously. Last-write-wins (LWW) isn't just a strategy; it's a data loss bug waiting to happen when latency spikes over 100ms. If you are building for 2026, you cannot rely on simple message passing to keep state in sync.
In my experience building the collaborative engine at a major fintech firm, we learned the hard way that WebSockets are just the pipe. The pipe doesn't care if the data arriving at the other end is logically sound. To build a system that feels like Google Docs or Figma, you need a mathematical framework for convergence. You need Conflict-free Replicated Data Types (CRDTs).
The Fallacy of the Central Server
Most developers start with a centralized mindset: the server is the source of truth. When User A types, they send a PATCH via WebSocket. The server applies it and broadcasts the new state to User B. This works in a lab. In the real world, User A has a 200ms lag on a subway, and User B is on high-speed fiber. By the time User A's update arrives, User B has already modified the same line. The server is forced to choose. If it chooses User B, User A's work vanishes. If it tries to merge them naively, you get garbled text.
CRDTs change the game by allowing every client to be a source of truth. They are data structures that can be updated independently and concurrently without coordination. Once all replicas have received the same set of updates (even in different orders), they are guaranteed to arrive at the identical state.
Choosing Your Weapon: Yjs vs. Automerge vs. Loro
By 2026, the landscape has matured significantly. Here is how I evaluate the players:
- Yjs: Still the gold standard for performance. Its selective update mechanism and binary encoding make it incredibly fast. I've seen Yjs handle documents with 100k+ operations without breaking a 16ms frame budget.
- Automerge: Better if you need a full history of every change (like Git). It’s more "correct" in its JSON-like structure but historically slower. With the latest Rust core, it’s catching up, but still heavier than Yjs.
- Loro: The newcomer built in Rust that is currently outperforming both in memory-intensive scenarios. If you are building a canvas-based tool with millions of nodes, look at Loro.
For 90% of apps, Yjs is the right choice because of its rich ecosystem of bindings (ProseMirror, Monaco, Quill).
Implementation: The WebSocket + CRDT Architecture
You need a transport layer that doesn't just broadcast strings, but handles binary state updates. Here is a production-ready setup using yjs and the Hocuspocus (a hardened Yjs WebSocket provider).
The Server (Node.js + Hocuspocus)
We use Hocuspocus because it handles the persistence layer and authentication hooks that the base Yjs provider ignores.
import { Hocuspocus } from '@hocuspocus/server';
import { Logger } from '@hocuspocus/extension-logger';
import { Database } from './db';
const server = new Hocuspocus({
port: 1234,
extensions: [new Logger()],
async onConnect(data) {
// Check JWT or session
const { token } = data.requestParameters;
if (!isValid(token)) throw new Error('Unauthorized');
},
async onLoadDocument(data) {
// Load the binary update from your Postgres/Redis
const doc = await Database.fetchDoc(data.documentName);
return doc; // Uint8Array
},
async onStoreDocument(data) {
// Persist the binary state
await Database.saveDoc(data.documentName, data.state);
},
});
server.listen();
The Client (React + Yjs)
On the client, we bind the CRDT to our UI state. Note the use of awareness for transient state like cursor positions.
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useState, useEffect } from 'react';
export const CollaborativeEditor = ({ docId, userId }) => {
const [text, setText] = useState('');
useEffect(() => {
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: 'ws://localhost:1234',
name: docId,
document: ydoc,
});
const yText = ydoc.getText('content');
// Sync UI with CRDT state
yText.observe(() => {
setText(yText.toString());
});
// Presence/Awareness (Cursors)
provider.awareness.setLocalStateField('user', {
name: `User ${userId}`,
color: '#ff5733',
});
return () => provider.destroy();
}, [docId]);
return (
<textarea
value={text}
onChange={(e) => {
// In a real app, use a proper binding like y-prosemirror
// This is a simplified example
yText.insert(0, e.target.value);
}}
/>
);
};
The Gotchas: What the Docs Don't Tell You
1. The Tombstone Problem
CRDTs never truly delete data; they mark it as deleted (tombstones) to ensure that if someone else modifies that data later, the system knows how to resolve it. If you have a document that lives for years and sees millions of edits, the memory footprint will grow. You must implement "state snapshots" or "garbage collection" strategies provided by the library. In Yjs, Y.encodeStateAsUpdate handles this by squashing the history into a single foundation block.
2. Large Binary Blobs in the DB
Storing a Yjs document as a single BYTEA or BLOB in your database is easy to start with, but it becomes a bottleneck. When the document hits 5MB, reading and writing the whole blob on every change will kill your DB I/O. Use a side-car pattern or a dedicated key-value store like Redis for the active binary state, and only flush to your persistent DB every few minutes or when the last user disconnects.
3. The "Awareness" Storm
Mouse cursor positions change 60 times a second. If you broadcast every single mousemove through your CRDT update loop, you will saturate the user's CPU. Always throttle awareness updates to 30ms or 50ms. Users won't notice the difference, but their laptop fans will.
Takeaway
If you are still sending JSON snapshots over WebSockets for shared state, you are building technical debt. Switch to Yjs today. Start by wrapping a small feature—like a shared task list or a settings page—using the Hocuspocus provider. Once you see how it handles offline reconnections and concurrent edits automatically, you'll never go back to manual conflict resolution.