Real-Time Collaboration With Yjs and CRDTs

Yjs primitives, awareness, providers, persistence, and the React glue that holds it together. Why I reach for CRDTs over OT in product code now.

The first time I shipped a multi-cursor editor to production, two creators opened the same canvas at the same time and one of them watched her work get steamrolled by the other’s drag. Not corrupted. Worse. Silently rewound, by about four seconds, on her screen only. The doc was fine on disk. I sat there at 10:40 p.m. on a Thursday replaying WebSocket frames in a Datadog tail, and realized our “real-time sync” was just last-writer-wins on a JSON blob with a bow on it.

That was the week I stopped pretending operational transform was something I’d write myself, and started leaning on Yjs hard. This is about that. Yjs primitives, the awareness layer nobody mentions until they need it, providers and persistence, the React glue, and why CRDTs are the right call for product code even if OT is more elegant on paper.

Why CRDTs over OT in product code

OT is beautiful. Google Docs is OT. It also assumes a central server that resolves operations in strict order, and it leaks that assumption into every client. Lose the server for 200ms and the transform queue starts second-guessing itself. Add offline editing and you’re writing a thesis.

CRDTs are uglier on paper, way easier in product code. Every op is commutative. Replicas apply ops in any order and converge. Server crashes, network blips, two laptops on a plane, none of it matters. The client carries the convergence guarantees.

For the visual app-builder I worked on at the creator economy platform, this was the call. Creators on flaky hotel wifi, designers reviewing on tablets, a backend that occasionally ate a write because someone shipped a schema migration past midnight. OT would have buried us. Yjs gave us a single shared type and let the rest of the architecture be boring.

Yjs primitives I actually use

Yjs ships a handful of shared types. In two years I’ve used four of them with any regularity: Y.Map, Y.Array, Y.Text, and Y.XmlFragment for one prosemirror integration. Everything else is glue.

import * as Y from "yjs";

export function createDocument() {
  const ydoc = new Y.Doc();

  const components = ydoc.getMap<Y.Map<unknown>>("components");
  const order = ydoc.getArray<string>("order");
  const meta = ydoc.getMap<unknown>("meta");

  meta.set("schemaVersion", 4);
  meta.set("createdAt", Date.now());

  return { ydoc, components, order, meta };
}

export function addComponent(
  ydoc: Y.Doc,
  id: string,
  payload: { type: string; props: Record<string, unknown> }
) {
  ydoc.transact(() => {
    const components = ydoc.getMap<Y.Map<unknown>>("components");
    const order = ydoc.getArray<string>("order");

    const node = new Y.Map();
    node.set("type", payload.type);
    node.set("props", payload.props);

    components.set(id, node);
    order.push([id]);
  }, "local-add");
}

The transact wrapper is the one thing I’d tell anyone new to Yjs to internalize. It batches updates into a single observable change. Without it, every set fires its own event, your React renders fight, and your undo manager turns one user action into seven entries.

Awareness, the part nobody mentions

Yjs has a sibling protocol called awareness. It’s an ephemeral key-value store per client, gossiped through the same provider, never persisted. Cursors, selections, “who’s typing” pills, presence dots in the user bar. All of it goes through awareness, not the doc.

This is the thing I got wrong the first time. I tried storing cursor positions in the Y.Map. Worked great until two creators selected the same layer at the same time and undo started yanking selection back and forth across machines.

import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";

type Presence = {
  user: { id: string; name: string; color: string };
  cursor?: { x: number; y: number };
  selection?: string[];
};

export function setupAwareness(
  ydoc: Y.Doc,
  provider: WebsocketProvider,
  me: Presence["user"]
) {
  const aw = provider.awareness;
  aw.setLocalStateField("user", me);

  const subscribers = new Set<(states: Map<number, Presence>) => void>();

  aw.on("change", () => {
    const states = aw.getStates() as Map<number, Presence>;
    subscribers.forEach((cb) => cb(states));
  });

  return {
    setCursor(cursor: Presence["cursor"]) {
      aw.setLocalStateField("cursor", cursor);
    },
    setSelection(selection: Presence["selection"]) {
      aw.setLocalStateField("selection", selection);
    },
    subscribe(cb: (states: Map<number, Presence>) => void) {
      subscribers.add(cb);
      return () => subscribers.delete(cb);
    },
  };
}

Selection lives in awareness. My local zustand store mirrors awareness for the UI. The doc never sees it. Cmd+Z doesn’t touch it. Sanity returns.

Providers, persistence, the React layer

Providers are how Yjs talks to other replicas. We ran y-websocket against a small Node service in front of an AWS-hosted Redis stream, and y-indexeddb on the client for offline. The indexeddb provider does most of the work people credit to “fancy sync”. A laptop closes mid-edit, opens 30 minutes later, the doc is already there before the websocket reconnects.

import { useEffect, useMemo, useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { IndexeddbPersistence } from "y-indexeddb";

export function useCollaborativeDoc(roomId: string, wsUrl: string) {
  const ydoc = useMemo(() => new Y.Doc(), [roomId]);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    const local = new IndexeddbPersistence(`doc:${roomId}`, ydoc);
    const ws = new WebsocketProvider(wsUrl, roomId, ydoc, {
      connect: true,
      params: { v: "4" },
    });

    local.once("synced", () => setReady(true));

    ws.on("status", (e: { status: string }) => {
      if (e.status === "disconnected") {
        // local edits keep flowing into indexeddb, ws will reconcile on reconnect
      }
    });

    return () => {
      ws.destroy();
      local.destroy();
      ydoc.destroy();
    };
  }, [roomId, wsUrl, ydoc]);

  return { ydoc, ready };
}

That hook is doing a lot quietly. The once("synced") from indexeddb is what unblocks the UI, not the websocket. The doc hydrates from local first, paints, then reconciles with the server in the background. The result is an editor that opens instantly even when the user is offline, which matters more than any tail-latency tuning I’ve ever shipped.

What I’d tell my Thursday-night self

Use Yjs. Don’t write OT. Put selection in awareness, not the doc. Wrap every mutation in transact. Hydrate from indexeddb first, the network second. Treat cache keys and identifiers as schema, not implementation. And when the editor feels slow, profile the doc, not the socket.

Takeaways

  • CRDTs win in product code because the client carries the convergence guarantees, not the server
  • Y.Map, Y.Array, Y.Text and transact cover most real apps
  • Selection, cursors, presence belong in awareness, never in the doc
  • Indexeddb persistence does most of the “fancy sync” work people credit to providers
  • When collaboration feels slow, look at the doc shape, not the transport

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.