All posts
15 Sep 2026

Trimming the Fat: Optimizing Yjs Document Bloat and WebSocket Payload Sizes at Scale

An engineering case study detailing how to profile Yjs memory leaks, implement smart document chunking, and slash WebSocket bandwidth using binary encoding and compression strategies.

Trimming the Fat: Optimizing Yjs Document Bloat and WebSocket Payload Sizes at Scale

Local-first software architectures are experiencing a massive renaissance. By shifting the source of truth to the client device, applications gain lightning-fast local read/write speeds, offline-first capabilities, and robust peer-to-peer sync options. At the heart of many modern local-first applications lies Yjs, a high-performance CRDT (Conflict-free Replicated Data Type) framework.

However, as local-first apps scale to handle large document trees—nested organizational wikis, massive design canvases, or deep code trees—developers inevitably hit a wall. Unoptimized Yjs documents suffer from severe memory bloat, sluggish garbage collection, and massive WebSocket payload sizes that choke server bandwidth during initial synchronization.

In this engineering case study, we will dissect how we diagnosed and solved memory leaks in large-scale Yjs documents, implemented smart structural chunking, and optimized our WebSocket sync pipelines using binary encoding and compression.


The Anatomy of Yjs Bloat

To understand why Yjs documents bloat, we must first look under the hood of how CRDTs store history. Unlike a standard JSON tree that only holds the current state, a Yjs document maintains an internal operation log (the transaction log, vector clocks, and structural nodes) to deterministically merge concurrent edits.

The Problem: Immutable History and Tombstones

Every insertion, deletion, and property update leaves a cryptographic trace in the Yjs state vector. When users collaboratively edit a large document tree over weeks or months, the internal state accumulates:ී

  • Tombstones: Deleted nodes are rarely stripped instantly; they remain as tombstones to ensure causal consistency across peers who might still be offline.
  • Client State Vectors: As dozens of unique client IDs interact with a document, the state vector map grows linearly, bloating state vector handshakes.
  • Deep Nesting and Granular Maps: Representing every UI element or file system node as a distinct Y.Map or Y.Array creates millions of internal CRDT structural wrappers, exponentially increasing JavaScript heap overhead.

Step 1: Profiling Yjs Memory Leaks and Heap Overhead

Our first warning sign came from production crash reports: browser tabs running our large document workspace were throwing Out of Memory (OOM) errors after roughly 45 minutes of active editing.

Setting Up the Memory Profiler

We reproduced the issue in a staging environment by simulating 50 concurrent agents performing randomized CRUD operations on a 50,000-node document tree. Using Chrome DevTools and Node.js --inspect flags, we took heap snapshots.

The culprit wasn’t our application logic; it was structural retention inside Y.Doc. Specifically:

  1. Unreleased observers: Event listeners attached via doc.on('update', ...) or nested sub-documents were never deregistered.
  2. Orphaned Sub-documents: We were spinning up Doc instances for nested folders using doc.getMap('folders').get(id) without properly destroying them when items were removed from the view layer.

The Fix: Lifecycle Management and Garbage Collection

We refactored our sub-document handling to enforce strict lifecycle bounds. Whenever a folder or nested document tree was archived or deleted from the main view, we explicitly called .destroy() on the sub-document and detached all observers.

typescript
import * as Y from 'yjs';

class DocumentManager {
  private subDocs = new Map<string, Y.Doc>();

  public getOrCreateSubDoc(parentDoc: Y.Doc, id: string): Y.Doc {
    if (this.subDocs.has(id)) {
      return this.subDocs.get(id)!;
    }

    // Use Y.getSubDoc if available, or manage via shared map
    const subDoc = parentDoc.getMap('subdocs').get(id) as unknown as Y.Doc || new Y.Doc();
    
    this.subDocs.set(id, subDoc);
    return subDoc;
  }

  public destroySubDoc(id: string) {
    const subDoc = this.subDocs.get(id);
    if (subDoc) {
      subDoc.destroy();
      this.subDocs.delete(id);
    }
  }
}

Additionally, we enforced State Garbage Collection. Yjs exposes internal mechanisms to clean up redundant history when synchronization with all known peers is guaranteed, though this must be handled carefully in offline-first scenarios.


Step 2: Implementing Smart Document Chunking

For massive document trees, forcing the client to load the entire CRDT state into memory upon connection is an anti-pattern. If a user only cares about a specific sub-folder in a 2GB enterprise repository, downloading the entire Yjs binary snapshot is wasteful.

Moving from Monolith to Modular Sub-documents (Y.Doc)

We broke our monolithic document tree into a lazy-loaded, hierarchical tree of Y.Doc instances using Yjs’s native sub-document capabilities.

  • Root Document: Contains only metadata, access control lists, and top-level folder pointers.
  • Leaf Documents: Individual folders or large rich-text files are isolated into their own sub-documents.

When a user expands a folder in the UI, our application requests only that specific sub-document’s binary update vector from the WebSocket server.

// Server-side lazy loading router
ws.on('message', async (message) => {
  const decoder = Y.decodeMessage(message);
  if (decoder.type === 'REQUEST_SUB_DOC') {
    const { docId } = decoder;
    const subDocBinary = await persistentStorage.loadSubDoc(docId);
    
    // Send back binary payload for just this sub-document
    ws.send(subDocBinary);
  }
});

Step 3: Optimizing WebSocket Payload Sizes

Even with chunking, initial sync payloads for active documents were hovering around 4.5MB of raw base64 data, causing high Time-to-Interactive (TTI) metrics on mobile devices.

1. Stripping Base64 and Moving to Pure Binary

A common mistake when bridging Yjs to WebSockets is converting binary updates (Uint8Array) to base64 strings or JSON wrappers. This inflates payload size by roughly 33% and incurs heavy serialization costs on both client and server.

We switched entirely to raw ArrayBuffer transmission over WebSockets:

// Client-side sending updates efficiently
import * as Y from 'yjs';
import { encodeUpdate } from 'yjs';

doc.on('update', (update: Uint8Array, origin: any) => {
  if (origin !== 'server') {
    // Send raw binary buffer directly over WebSocket
    websocket.send(update);
  }
});

2. Implementing Delta Compression (deflate / gzip)

While Yjs updates are already binary, they contain repeated structural keys and operation logs that compress exceptionally well. We integrated pako (a fast zlib port for JavaScript) to compress large binary state updates before they hit the wire.

import pako from 'pako';
import * as Y from 'yjs';

function sendOptimizedUpdate(doc: Y.Doc, ws: WebSocket) {
  const stateAsUint8 = Y.encodeStateAsUpdate(doc);
  
  // Compress using DEFLATE
  const compressed = pako.deflate(stateAsUint8);
  
  // Prepend a 1-byte header to indicate compression type
  const payload = new Uint8Array(compressed.length + 1);
  payload[0] = 0x01; // 0x01 = zlib compressed
  payload.set(compressed, 1);
  
  ws.send(payload);
}

function handleIncomingPayload(payloadBytes: Uint8Array, doc: Y.Doc) {
  const compressionType = payloadBytes[0];
  const data = payloadBytes.subarray(1);

  let rawUpdate: Uint8Array;
  if (compressionType === 0x01) {
    rawUpdate = pako.inflate(data);
  } else {
    rawUpdate = data;
  }

  Y.applyUpdate(doc, rawUpdate);
}

3. Debouncing and Batching High-Frequency Edits

During rapid user actions (such as dragging a node across a canvas or typing rapidly in a shared code block), Yjs can fire dozens of update events per second. Sending these immediately floods the WebSocket queue.

We implemented a sliding-window batcher that aggregates updates over a 50ms window before compressing and broadcasting them:

let pendingUpdates: Uint8Array[] = [];
let timeoutId: NodeJS.Timeout | null = null;

function queueUpdateForSync(update: Uint8Array, ws: WebSocket) {
  pendingUpdates.push(update);

  if (!timeoutId) {
    timeoutId = setTimeout(() => {
      // Merge all pending updates into a single concise state update
      const merged = Y.mergeUpdates(pendingUpdates);
      const compressed = pako.deflate(merged);
      
      ws.send(compressed);
      
      pendingUpdates = [];
      timeoutId = null;
    }, 50);
  }
}

Benchmarks and Results

After rolling out structural chunking, binary WebSocket streams, and zlib payload compression across our production clusters, the metrics spoke for themselves:

Metric Before Optimization After Optimization Improvement
Initial Document Sync Payload 4.8 MB (Base64 JSON) 380 KB (Binary + Zlib) 92% Reduction
Peak Browser Heap (50k nodes) 750 MB + OOM Crashes 145 MB 80% Reduction
Time to Interactive (TTI) 3.4 seconds 410 ms 88% Faster
WebSocket Bandwidth (Hourly) 1.8 GB / user 140 MB / user 92% Savings

Conclusion

Yjs is an extraordinarily powerful engine for building local-first applications, but scaling it requires treating CRDT memory and network transport with the same rigor as traditional backend databases. By moving away from monolithic document states, aggressively managing sub-document lifecycles, and utilizing raw binary transport paired with compression, you can scale local-first document trees to millions of nodes without breaking a sweat.

More posts