Local-First and CRDTs: From Naive Sockets to a Scalable Collaborative Editor
Why broadcasting keystrokes over Socket.IO falls apart, how OT and CRDTs keep documents converging, what local-first buys your UX, and how we built Cynoia's collaborative editor with Yjs, Lexical, IndexedDB persistence, and Socket.IO scaled through the Redis adapter.
Local-First and CRDTs: From Naive Sockets to a Scalable Collaborative Editor
There is a class of bug report no amount of debugging can fix: "my teammate's copy of the document is different from mine." No error was thrown. No request failed. Two people typed at the same time, and the system silently produced two truths. If your collaborative editor is built as a message relay, this is not a bug you have — it is a bug you are, and the only fix is a different design.
I hit this problem at Cynoia, an all-in-one collaborative workspace: chat, video calls, projects, notes, and calendars in a single app. One piece of that promise was missing — collaborative document editing — because a workspace that aims to replace your whole stack cannot send people back to Google Docs every time two teammates need to write together.
So we built it: a collaborative editor where 50+ people can type in the same document at once. This post is the theory I wish I had read first (operational transformation, CRDTs, local-first) and the architecture we actually shipped with Yjs, Lexical, and Socket.IO.
The Naive Solution, and Why It Fails
The first idea everyone has: you already have Socket.IO, so just broadcast edits. Client types, you emit { insert: "l", index: 3 }, the server relays it to the room, everyone applies it. No CRDT, no theory. It even works in the demo, with one person typing.
Then two people edit at once:
Three separate failures are hiding in that diagram:
- Divergence. Positional operations are only valid against the exact document state they were computed on. Concurrent edits shift each other's indexes, replicas apply ops in different orders, and the documents drift apart permanently. No error is ever thrown. The bug reports say "my teammate's copy is different from mine," and there is nothing to fix, because the design cannot converge.
- Lag. The obvious patch is to make the server authoritative: do not apply your own keystroke until the server confirms it. Now every character you type waits for a network round trip. On a 150ms connection the editor feels broken. Typing is the one interaction where humans notice every millisecond.
- Lost updates. The other obvious patch, periodically syncing the full document with last write wins, is worse: whoever saves last silently deletes everyone else's sentences.
This is not a bug in Socket.IO. Transport was never the problem. Concurrent editing is a state merge problem, and a relay has no answer to it.
Two families of designs can actually merge concurrent edits, and the one you pick decides your server topology, your offline story, and your scaling model — so the theory detour that follows is load-bearing, not academic.
Operational Transformation: Rewrite the Operation
Operational Transformation (OT) is the classic answer, and it is what Google Docs runs on. The insight: a concurrent operation is not wrong, it is stale. Its index was computed against a document that has since changed. So before applying it, rewrite it.
Concretely, a central server puts all operations into one global order. When an operation arrives that raced with others, the server transforms it: the operation's position is recomputed to account for every concurrent operation ordered before it, so each client receives an op that is valid against its own current state.
It works. Google proved it at planetary scale. But the fine print is brutal:
- You need a correct transform function for every pair of operation types. Add one operation kind and you revisit the whole matrix.
- The correctness properties (the literature calls them TP1 and TP2) are notoriously easy to get wrong. Several published OT algorithms were later proven to diverge.
- The whole scheme leans on that central server deciding a single order. Your collaboration model is welded to a server round trip, and true offline or peer-to-peer sync fights the design.
CRDTs: Fix the Data Structure Instead
Conflict-free Replicated Data Types attack the problem one level lower. Where OT keeps positional operations and rewrites them at sync time, a CRDT abandons positions entirely: every character carries a stable identity (roughly, a unique ID plus a reference to the character it was inserted after). "Insert after character X" stays true no matter what else happened concurrently, so operations never need rewriting. The merge is commutative, associative, and idempotent: apply any set of updates in any order, on any replica, and you provably get the same document. No central arbiter, no transform matrix.
That is the whole OT-versus-CRDT trade in one sentence: OT keeps the data simple and makes coordination hard; a CRDT makes the data richer so that coordination becomes trivial.
The two libraries that matter in JavaScript:
- Yjs: engineered relentlessly for text editing performance, with compact binary updates, efficient encoding of insertion runs, tombstone garbage collection, and first-class editor bindings (Lexical, ProseMirror, Monaco, CodeMirror). This is what we used.
- Automerge: a JSON-shaped CRDT with full change history, born from the local-first research community (the Ink & Switch lab). Historically heavier for large text documents; a strong choice when you want document history and Rust/WASM portability.
The trade you accept with CRDTs: deleted content leaves tombstones, documents carry their editing history until compacted, and interleaving anomalies in plain text exist in theory. In practice, for a product editor, Yjs made these someone else's solved problem.
Local-First: What the Merge Model Buys Your UX
CRDTs enable an architecture the Ink & Switch essay named local-first software: the copy of the data your app reads and writes is the one on the device, and the network is just how replicas gossip in the background.
Note the second arrow out of the Yjs doc: persistence to IndexedDB. Yjs ships an official provider for this, y-indexeddb, and wiring it up is one line:
import { IndexeddbPersistence } from "y-indexeddb";
const persistence = new IndexeddbPersistence(docName, ydoc);Every update is written to the browser's IndexedDB as it happens, which closes the reliability gaps that no network layer can:
- Refresh and crash survival. The document is on disk before it is on the wire. Kill the tab mid-sentence and nothing is lost.
- Instant opens. On revisit, the editor loads the local copy immediately instead of spinning while the server responds, then syncs the difference in the background.
- Real offline editing. Connection drops become invisible: edits accumulate locally and merge on reconnect, and the CRDT guarantees the merge is safe.
- Less data on the wire. Because the client already holds the document, sync only exchanges the updates each side is missing, not the full state.
You can feel what this whole approach buys in the products you already use:
- Google Docs is the OT flagship: server-ordered, superb while online, and its offline mode is a carefully engineered special case rather than a natural property of the design.
- Notion is server-first with block-granularity syncing. Open a large page on a slow connection, or go offline mid-edit, and you feel the server sitting between you and your data.
- Linear is the strongest mainstream argument for local-first: a sync engine keeps a full local replica, every interaction hits local state first, and mutations sync in the background. That is why the app feels instant in a way most SaaS does not. Notably, Linear is not a text CRDT. Local-first is an architecture, and CRDTs are one way to get the merge guarantees it needs.
The honest cost sheet, because local-first is not free:
- Persistence becomes an update log plus periodic compaction, not a
contentcolumn - Permissions get harder: merges happen on clients, so authorization must gate the transport and the replica, not a save endpoint
- Undo must be scoped to the local user's own changes (global undo in a shared document is chaos)
- Presence (cursors, selections, who is here) is a separate ephemeral protocol, not part of the document
- Schema migrations now happen across replicas you do not control
What We Built at Cynoia
Our constraints: a NestJS backend, Socket.IO already in production for other real-time features, and a product that needed rich text. The stack that shipped:
- Lexical as the editor, bound to a Yjs document; the binding maps Yjs updates to editor state in both directions
- y-indexeddb persisting every replica in the browser, for the crash survival, instant opens, and offline editing described above
- Socket.IO rooms as the transport for two channels: Yjs document updates and ephemeral awareness (cursors, selections, presence)
- The Socket.IO Redis adapter for horizontal scale; this is the piece most tutorials skip
- Debounced persistence: merge updates in memory and flush the compacted document state every few seconds or on close, instead of writing per keystroke
- RBAC at both doors: owner, editor, commenter, viewer roles enforced on the WebSocket connection and the HTTP API, so a read-only user cannot push updates through the socket
The single-server version of this is a weekend project. The production question is: what happens when you run more than one API node and Alice lands on node 1 while Bob lands on node 2?
The Redis adapter makes every room.emit fan out across nodes via pub/sub, so a document's editors do not need to share a server. And because Yjs updates are commutative, cross-node delivery order does not matter: the CRDT absorbs whatever ordering the network produces. This is the quiet superpower of the design. The property that fixes concurrent typing is the same property that makes horizontal scaling safe. An OT system in the same topology would need a single ordering authority per document; Yjs let every node stay a peer.
Reads went through a three-tier path (in-memory Yjs doc for active documents, Redis for recently open ones, Postgres as the source of truth), which cut database load by roughly 40% versus the naive per-edit write path.
Key Lessons
Transport is the easy 20%. Socket.IO, rooms, reconnects: all solved. The 80% is state, meaning merge semantics, persistence, permissions, presence, and undo. Budget accordingly.
Do not build your own merge. The naive relay fails quietly and permanently. OT is a research career. Yjs is a library import. This is one of the clearest build-versus-buy calls in frontend engineering.
Persist on the client, not just the server. y-indexeddb is one line of code, and it converts "the network hiccupped" from a data-loss incident into a non-event. Reliability layers compound: disk first, then socket, then server.
Local-first is a UX decision disguised as an architecture decision. Zero-latency typing and offline resilience are not optimizations of the synchronous design. They are properties the synchronous design can never have.
Let the CRDT do the scaling work. Commutative updates mean nodes never coordinate on order. The Redis adapter only has to deliver, not sequence.
Results
- 50+ concurrent editors on a single document, typing with zero perceived input latency
- Documents provably converge; the divergence class of bugs is structurally impossible, not merely rare
- Edits survive tab kills, crashes, and offline stretches, because every replica persists to IndexedDB before it touches the network
- Horizontal scale by adding nodes behind the Redis adapter, with no per-document server affinity
- ~40% lower database load from debounced, compacted persistence instead of per-keystroke writes
Related Reading
- Building a Real-Time Collaborative Editor — the presence, offline, and permissions layers around this same system