The problem: screen share and dictate

Anyone who has run a pairing session, a technical interview, or a live coding class knows the ritual. One person shares their screen. Everyone else watches a compressed video of a text editor and dictates changes out loud: "no, line 42 — the second argument — no, delete that bracket." The person typing becomes a bottleneck; everyone else becomes a passenger. The moment two people need to touch the same file at the same time, the tooling gives up.

The obvious fix is "Google Docs, but for code" — and that framing is exactly why so many attempts stall. Prose tolerates small inconsistencies; code does not. A misplaced character in a paragraph is a typo. A misplaced character in a program is a syntax error that breaks the session for everyone. Code also carries structure the editor must respect while edits fly in from multiple people: indentation, token boundaries, cursor positions inside lines that other people are actively rewriting. We built SynCode to solve this properly: shared rooms, multiplayer editing, live cursors, and sync fast enough that it feels like one keyboard.

Why the naive approaches break

Last-write-wins is the first thing everyone tries: each client periodically sends its full document, the server keeps the latest copy. It works flawlessly in a demo with one careful user. With two real users, keystrokes interleave, and whoever's payload lands last silently erases the other person's work. Users experience it as the editor "eating" their code — the fastest possible way to lose their trust.

Locking is the second attempt: only one person may edit at a time, or each person locks a region. This is technically correct and practically useless. The whole point of collaborative editing is fluidity — pointing at a function and fixing it while your partner works two lines below. Lock-based editing turns a conversation back into a queue.

Naive operation streaming gets closer: send each keystroke as an operation like "insert x at position 128." But positions are relative to a document state, and networks are not polite. Operations arrive out of order, duplicated after a retry, or delayed behind a flaky mobile connection. An insert at position 128 is meaningless if someone else deleted twenty characters at position 90 while the message was in flight. Without a principled answer to ordering and concurrency, every reconnect is a chance to corrupt the document.

The architecture we chose

SynCode is React on the client, Node on the server, WebSockets in between, and a CRDT-style replication model at the core. The key idea of conflict-free replication is to stop describing edits by fragile numeric positions and start describing them against stable identities. Every inserted character gets a unique ID (site ID plus a per-site counter), and operations reference those IDs — "insert after character abc:41" — rather than offsets. Because identities never shift, concurrent operations can be applied in any order on any replica and every client converges to the same document, without a central arbiter re-transforming each operation.

Around that core, the structure is deliberately boring:

Rooms as the unit of state. Each room owns one document, its operation log, and its member list. A WebSocket connection joins a room, receives a snapshot plus recent ops, and starts streaming.

Presence separated from document ops. Cursor positions, selections, and "who is here" updates travel on a separate channel with different guarantees. Document operations must be reliable and durable; presence must only be fresh. A cursor update from 400 ms ago is worthless — drop it, never queue it behind edits.

Reconnection with catch-up. Clients track the last operation they have applied. On reconnect, they present that version vector and the server replays only what they missed — or sends a fresh snapshot if they are too far behind. Closing a laptop mid-session and reopening it should be a non-event.

 syncode / protocol.ts
// every op is uniquely identified and safely re-appliable
type Op = {
  id:     `${SiteId}:${Counter}`,  // stable identity, not an offset
  kind:   "insert" | "delete",
  after:  CharId,                   // anchor character, survives edits
  value?: string,
}

type Message =
  | { t: "op",       room: string, op: Op }
  | { t: "presence", room: string, cursor: CharId }   // lossy, latest-wins
  | { t: "sync",     room: string, since: VersionVector } // reconnect catch-up

Notice what the message shape encodes: document ops carry identity and can be replayed; presence carries only the latest state and can be dropped; sync requests carry a version vector so the server knows exactly what a returning client is missing.

Hard-won lessons

  1. Make every operation idempotent. Networks retry, and your reconnect logic will eventually resend something the server already has. Because each op carries a unique ID, applying it twice is a no-op by construction. If your protocol has any message where "received twice" differs from "received once," you have a bug waiting for a bad Wi-Fi day.
  2. Be disciplined about ordering, not clever. Wall-clock timestamps from client machines are lies — skewed, occasionally backwards. Use logical counters (Lamport-style) for causality and let the CRDT's deterministic tie-break resolve true concurrency. The rule must be boring and identical on every replica.
  3. Test with simulated latency from day one. On localhost, every architecture works, including the broken ones. We wrapped the transport in a test harness that injects delay, jitter, reordering, duplication, and hard disconnects, then asserted convergence: after any interleaving of ops, all replicas must hold byte-identical documents. Most of our real bugs were found by this harness, not by users.
  4. Cursor UX is where "correct" becomes "instant". Apply local keystrokes immediately and reconcile remote ops around them — never wait for a round trip to echo the user's own typing. Remap remote cursors through incoming edits so they stay glued to the right character. Interpolate cursor motion over ~50 ms instead of teleporting. None of this changes correctness; all of it is the difference between a tool that feels alive and one that feels like lag.

If you're not building an editor

Almost nothing above is specific to code editing. Rooms, a reliable operation channel, a lossy presence channel, and reconnection catch-up are the machinery behind any product where several people look at changing state together: dashboards that update the moment a metric moves, quoting and intake forms two teammates fill in at once, ops consoles where a dispatcher and a field team see the same board, approval queues that never need a refresh button.

The pattern we see with clients is that "real-time" gets dismissed as a gimmick because the first attempt — usually polling or a naive socket broadcast — was fragile. Built on the right foundations, it is simply a capability: state that stays true across every screen that is looking at it. That is what we build in our custom web platform work, and SynCode is the fullest expression of it in our portfolio.

If your team spends its day asking "can you refresh?" or emailing spreadsheets that are stale on arrival, the fix is the same architecture described here — minus the code editor.