Skip to content

IT: Y.js sync echo causes 800KB+ broadcasts when client joins collaboration session #1874

@vyasgiridhar

Description

@vyasgiridhar

What's on your mind?

When a new client joins a collaborative editing session, they receive the full document state from the server (~900KB), but then immediately echo it back as a SYNC_UPDATE. This creates a feedback loop where every join triggers massive broadcasts to all other connected clients.

Current Behavior

  1. Client A is editing a document (~850KB of Y.js state)
  2. Client B joins the session
  3. Server sends Client B the full document state (SYNC_STEP2, ~892KB) ✅ correct
  4. Client B's browser sends the entire document back as SYNC_UPDATE (~852KB) ❌ bug
  5. Server broadcasts this to Client A
  6. Both clients freeze while processing the redundant 852KB update

Timeline from our logs:

13:54:32.945 - Client B receives 892KB SYNC_STEP2 from server
13:54:32.950 - Y.Doc update event fires with origin=provider (correct, not broadcast)
13:54:33.308 - Y.Doc update event fires AGAIN with origin="Object" (wrong!)
13:54:33.308 - Client B sends 852KB SYNC_UPDATE back to server
13:54:33.434 - Server broadcasts 852KB to Client A

Expected Behavior

When a client receives state from the server, it should NOT echo that state back. The sync protocol should be:

  • Client → Server: SYNC_STEP1 (state vector, ~10 bytes)
  • Server → Client: SYNC_STEP2 (missing state, ~900KB)
  • Client → Server: SYNC_STEP2 (any state server is missing, likely ~0 bytes)
  • No SYNC_UPDATE echo

Root Cause Analysis

The issue is in the interaction between y-prosemirror and y-websocket:

  1. Y.js receives the update from WebSocket with origin = provider
  2. Y.js fires change events on shared types (Y.XmlFragment, etc.)
  3. y-prosemirror's observeDeep handler catches these and updates ProseMirror
  4. ProseMirror state change triggers y-prosemirror's appendTransaction
  5. This creates a NEW Y.js transaction with origin = ySyncPluginKey (not the provider!)
  6. y-websocket's _updateHandler sees origin !== this and broadcasts

The key evidence from browser console:

// First update - correctly ignored
{ updateSize: 892985, origin: "F", isLocal: false }  // "F" is minified provider

// Second update - incorrectly broadcast (355ms later)
{ updateSize: 852042, origin: "Object", isLocal: false }  // Different origin!

Impact

  • Network: Every client join broadcasts ~850KB × (N-1) clients
  • Performance: All clients freeze for 300-500ms processing redundant data
  • UX: Multi-user editing becomes unusable with documents >500KB

Suggested Fix

In y-prosemirror's sync plugin, the _typeChanged handler should check if the transaction origin is a network source before triggering appendTransaction. Something like:

_typeChanged(events, transaction) {
  // Skip if this change came from the network (WebSocket provider)
  if (transaction.origin && transaction.origin.ws) return;
  // ... rest of handler
}

Or alternatively, y-websocket could track recently-received update hashes and skip broadcasting duplicates.

Environment

  • SuperDoc Version: Using collaboration-yjs from superdoc packages
  • y-websocket: Standard npm package
  • y-prosemirror: Standard npm package
  • Y.js: 13.x
  • Document size: ~850KB Y.js state (typical legal document)
  • Browser: Chrome/Firefox (reproducible in both)

Steps to Reproduce

  1. Open a collaborative document with substantial content (~100+ pages)
  2. Have Client A connected and idle
  3. Open the same document in a new browser tab (Client B)
  4. Observe network traffic - Client B sends ~850KB SYNC_UPDATE immediately after joining
  5. Observe Client A's UI freezes briefly

Related Issues

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions