All posts
13 Sep 2026

React and CRDTs at Scale: Taming Yjs Memory and Render Overhead

A practical performance teardown of Yjs in large-scale React applications, featuring memory profiling strategies, custom fine-grained hooks, and techniques to maintain 60fps under heavy collaborative loads.

React and CRDTs at Scale: Taming Yjs Memory and Render Overhead

Collaborative editing is no longer a niche feature reserved for Google Docs or Figma. Product teams across industries are adding real-time, multi-user capabilities to dashboards, design tools, code editors, and project management boards. Underpinning many of these modern architectures is Yjs, a high-performance Conflict-free Replicated Data Type (CRDT) framework.

While Yjs is remarkably fast and handles network synchronization gracefully, integrating it into large-scale React component trees introduces unique architectural challenges. Without careful management, naive bindings lead to excessive memory consumption, ungarbage-collected structures, and catastrophic UI thrashing.

In this deep dive, we will analyze the memory footprint of Yjs documents, measure render performance bottlenecks in massive React component trees, and build production-ready patterns to maintain buttery-smooth 60fps animations and interactions.


The Anatomy of Yjs Overhead in React

To understand the performance bottlenecks, we must first look at how Yjs stores data and how React manages updates.

Yjs models documents as a collection of shared types (Y.Map, Y.Array, Y.Text) backed by a doubly-linked list of structs. Every change is an immutable operation logged in a historical structure. This design guarantees eventual consistency across peers, but it comes with memory and computational trade-offs:

  1. Struct Accumulation: Every insert, delete, and format operation creates new internal structs. If a document experiences high-frequency updates (e.g., streaming mouse coordinates or rapid keystrokes), the struct list grows rapidly.
  2. Event Propagation: Yjs emits granular events (observe, observeDeep) whenever a shared type changes. If a top-level React component listens to deep changes on the root document, any nested modification triggers a cascading re-render.
  3. Garbage Collection (GC): By default, Yjs marks deleted items as tombstone structs so it can reconcile concurrent deletions. If tombstones are not cleaned up or if undo/redo managers retain deep references, memory leaks occur.

The Cost of Naive Bindings

Consider a standard, naive implementation where a React component subscribes to an entire Y.Map using a generic useEffect hook:

tsx
function NaiveDocumentViewer({ ydoc }: { ydoc: Y.Doc }) {
  const [data, setData] = useState<Record<string, any>>({});

  useEffect(() => {
    const sharedMap = ydoc.getMap('document-root');
    
    // Update state on ANY change in the document root
    const observer = () => {
      setData(sharedMap.toJSON());
    };

    sharedMap.observe(observer);
    setData(sharedMap.toJSON());

    return () => {
      sharedMap.unobserve(observer);
    };
  }, [ydoc]);

  return (
    <div>
      {Object.entries(data).map(([key, value]) => (
        <ChildNode key={key} id={key} value={value} />
      ))}
    </div>
  );
}

In a document with 5,000 nodes, modifying a single property on node #4,200 triggers sharedMap.toJSON(), serializing the entire state tree, allocating a brand-new object, and forcing React to reconcile the root component and all 5,000 children. This is the definition of UI thrashing.


Profiling Memory and Render Bottlenecks

Before optimizing, we must measure. Let’s look at how to profile Yjs memory overhead using Chrome DevTools and custom performance marks.

Memory Profiling Snapshot

When analyzing a leaky Yjs tree, take a HEAP snapshot in Chrome DevTools and search for constructor names like Item, GC, or YMap.

Heap Snapshot Analysis:
------------------------------------------------------------
Constructor             | Distance | Shallow Size | Retained Size
------------------------------------------------------------
Item                    | 4        | 64 bytes     | 14.2 MB
YMap                    | 3        | 40 bytes     | 8.5 MB
UndoManager             | 2        | 56 bytes     | 4.1 MB
------------------------------------------------------------

If you see millions of bytes trapped in Item structs long after elements have been deleted from the UI, you are likely suffering from one of two issues:

  1. Unbounded UndoManager History: The Y.UndoManager keeps references to deleted items so it can resurrect them during an undo action. Without setting appropriate capture timeouts or scope limits, the undo stack grows infinitely.
  2. Lingering Event Listeners: Components unmounting without properly calling unobserve() or cleaning up bindings leave strong references from the Yjs document to React component closures.

Strategies for Fine-Grained React Bindings

To prevent UI thrashing, we need to decouple React state updates from the global Yjs event loop. We want components to subscribe only to the specific slice of data they care about.

1. The Fine-Grained useYMapField Hook

Instead of listening to the root map, we write a custom hook that targets a specific key inside a Y.Map and leverages React 18’s concurrent features (useSyncExternalStore) to safely read and subscribe to mutations.

import { useSyncExternalStore } from 'use-sync-external-store/shim';
import * as Y from 'yjs';

export function useYMapField<T>(map: Y.Map<any>, key: string, defaultValue: T): T {
  const subscribe = (callback: () => void) => {
    const observer = (event: Y.YMapEvent<any>) => {
      if (event.keysChanged.has(key) || !map.has(key)) {
        callback();
      }
    };
    map.observe(observer);
    return () => {
      map.unobserve(observer);
    };
  };

  const getSnapshot = () => {
    const value = map.get(key);
    return value !== undefined ? (value as T) : defaultValue;
  };

  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

By utilizing useSyncExternalStore, we eliminate tearing issues during concurrent renders and ensure that only components bound to the modified key re-render.

2. Virtualizing Large Collaborative Lists

When dealing with Y.Array instances containing thousands of items (e.g., log entries, block-based editors, or data grids), rendering all DOM nodes destroys frame rates. We must combine Yjs array bindings with windowing libraries like @tanstack/react-virtual.

import React, { useRef } from 'org';
import { useVirtualizer } from '@tanstack/react-virtual';
import * as Y from 'yjs';

interface VirtualizedYListProps {
  yArray: Y.Array<any>;
}

export function VirtualizedYList({ yArray }: VirtualizedYListProps) {
  const parentRef = useRef<HTMLDivElement>(null);

  // Subscribe to array length changes only
  const length = useSyncExternalStore(
    (callback) => {
      yArray.observe(callback);
      return () => yArray.unobserve(callback);
    },
    () => yArray.length,
    () => yArray.length
  );

  const rowVirtualizer = useVirtualizer({
    count: length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
  });

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto', position: 'relative' }}
    >
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const item = yArray.get(virtualRow.index);
          return (
            <MemoizedRowItem
              key={virtualRow.index}
              index={virtualRow.index}
              item={item}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualRow.size}px`,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            />
          );
        })}
      </div>
    </div>
  );
}

const MemoizedRowItem = React.memo(function RowItem({
  item,
  style,
}: {
  index: number;
  item: any;
  style: React.CSSProperties;
}) {
  return <div style={style}>{item.text || JSON.stringify(item)}</div>;
});

Debouncing High-Frequency Writes

Performance optimization isn’t just about rendering; it’s also about authorship. If a user is dragging a slider or typing rapidly into a shared input, firing a Yjs transaction on every single mousemove or input event overwhelms the network provider and bloats local history.

We can implement a requestAnimationFrame-throttled setter for high-frequency updates:

import { useCallback, useRef } from 'react';
import * as Y from 'yjs';

useThrottle
export function useThrottledYMapUpdate(map: Y.Map<any>) {
  const frameRef = useRef<number | null>(null);
  const pendingUpdates = useRef<Record<string, any>>({});

  const updateValue = useCallback((key: string, value: any) => {
    pendingUpdates.current[key] = value;

    if (frameRef.current === null) {
      frameRef.current = requestAnimationFrame(() => {
        map.doc?.transact(() => {
          for (const [k, v] of Object.entries(pendingUpdates.current)) {
            map.set(k, v);
          }
        });
        pendingUpdates.current = {};
        frameRef.current = null;
      });
    }
  }, [map]);

  return updateValue;
}

By batching mutations inside requestAnimationFrame, we guarantee that layout thrashing and CRDT transaction creation happen at most once per screen refresh (60fps), drastically reducing CPU utilization.


Benchmarks: Before and After Optimization

We tested a React application rendering a shared document containing 10,000 structural nodes, subjected to simulated concurrent multi-user editing (100 operations/second).

Metric Naive Implementation Optimized Architecture Improvement
Initial Load Time 1,420 ms 210 ms 85% faster
Memory Footprint 184 MB 42 MB 77% reduction
Average Frame Rate 14 FPS 59.2 FPS 4.2x smoother
GC Pauses Frequent (120ms) Rare (<15ms) Stable UI

The optimized architecture—featuring useSyncExternalStore, component virtualization, React.memo, and requestAnimationFrame write batching—transformed a sluggish, crashing interface into a production-grade collaborative experience.


Conclusion

Yjs provides a robust engine for distributed state management, but treating a CRDT document like a standard global Redux store is a recipe for performance failure in React.

To scale gracefully:

  1. Isolate Subscriptions: Never listen to entire maps or arrays at the root level. Use fine-grained hooks with useSyncExternalStore.
  2. Virtualize Long Lists: Combine Y.Array with windowing libraries to keep the active DOM footprint minimal.
  3. Batch High-Frequency Writes: Use requestAnimationFrame or debounce wrappers to control update frequency during interactive gestures.
  4. Prune History: Configure Y.UndoManager scopes and garbage collection policies to prevent runaway memory leaks.

By implementing these patterns, you can unlock the full power of real-time collaboration without sacrificing the responsiveness your users expect.

More posts