How We Built a Multiplayer Code Editor with Cloudflare, Yjs, and CodeMirror | CoderScreen<br>Skip to main contentGitHubGet Started
Back to BlogCoderScreen runs live technical interviews. An interviewer and a candidate get on a call and need to write, edit, and run code together, in one shared editor, at the same time. That's a multiplayer code editor: two people typing into the same file, each seeing the other's changes as they happen, with a real runtime behind it.
On paper it's simple. Two people open the same URL, one interviewer and one candidate, and everything stays in sync. Both cursors move. One of them hits Run and they both watch the same output come back. The hard part hides inside "stays in sync." The obvious way to build it breaks the moment two people type at once. This post builds up the single-file version and saves multi-file workspaces for a follow-up. First, why the obvious way fails.
Why "just send the keystroke" doesn't work
The obvious approach is to broadcast keystrokes. Someone types, you send everyone else in the room a small instruction like insert "X" at position 5, and each client applies it. With one person editing, that works fine. With two, it quietly corrupts the document. Everything else in this post exists to avoid that, so it's worth seeing exactly how it breaks.
A position only means something against one exact version of the document. In a live session, everyone's version is changing at once. By the time your insert at 5 reaches the other person, their position 5 has moved, because they've been typing too. Each client applies the incoming edits to a document that has already shifted, in a slightly different order, and the two copies drift apart. Raw operations aren't enough on their own. You need something underneath that makes edits safe to apply in any order.
Say both screens show hello. At the same instant, Alice inserts X at position 0 and Bob inserts Y at position 0. Each sends the other their operation as-is: insert("X", 0) and insert("Y", 0). Alice applies Bob's op to her document, which already starts with X, and gets YXhello. Bob applies Alice's to his, which already starts with Y, and gets XYhello. Both edits landed, but in opposite orders, because each position was computed against a document that had already changed. Two keystrokes in, and the screens disagree.
Both screens start from "hello". At the same moment,<br>Alice inserts "X" at position 0 and Bob inserts "Y" at position 0.
NAIVE — broadcast raw positions<br>Alice: types "X" -> "Xhello", then applies Bob's insert@0 -> "YXhello"<br>Bob: types "Y" -> "Yhello", then applies Alice's insert@0 -> "XYhello"<br>Alice ends at "YXhello", Bob ends at "XYhello" — they disagree.
CRDT — every insert carries a stable id<br>Alice's "X" = id a1, Bob's "Y" = id b1<br>Both clients order the two inserts the same way (a1 before b1)<br>Alice ends at "XYhello", Bob ends at "XYhello" — they agree.
Now add fast typing, network jitter that reorders messages, and a reconnect that replays edits made while offline. Position-based patches don't stand a chance. What you actually need is a data structure where concurrent edits combine to the same result no matter what order they arrive in.
CRDTs to the rescue
That data structure is a CRDT , short for Conflict-free Replicated Data Type. The idea: independent copies can be edited at the same time and always merge back to the same result, whatever order the edits show up in. No central referee decides who wins. The merge rules live in the data itself, so each client applies every change locally and still ends up with an identical document. It's the same family of technique behind the live editing in Figma and Apple Notes. (The other well-known option is Operational Transformation, which Google Docs uses. OT needs a smart, authoritative server to rewrite every edit; a CRDT lets ours stay dumb and just pass updates along.)
For text, the trick is to stop thinking in positions. A text CRDT gives every character a permanent id and remembers where it sits relative to its neighbors: this X goes after that specific character, not "at index 5." Indices move as the document changes; the link between two particular characters doesn't. So when two people insert at the same spot, their characters have different ids and a fixed tie-break rule, and every client sorts them the same way. Deletes work the same way. A character gets marked as removed instead of shifting everything after it. Because every operation points at a stable id rather than a moving index, you can replay operations in any order and still land in the same place. It's also why a candidate can keep typing through a Wi-Fi drop and have those edits merge cleanly on reconnect.
Yjs implements all of this so we don't have to. You work with shared types that look familiar: Y.Text for a string, Y.Map for an object, Y.Array for a list, all hanging off one root document, a Y.Doc. Every edit produces a small binary update . Hand that update to any other...