From 488b3c571ceff7af1b2f91d69e643ab69779c076 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:53:27 +0200 Subject: [PATCH] perf: stop note co-editing echoing every remote update back to the server (#28185) Every client in a note re-broadcasts each update it receives, with a full content snapshot attached, and the server appends each echo to the document log and writes the note again. Traffic and note writes therefore scale with the number of people who have the note open: every extra participant adds one more full echo of every keystroke. Remote updates are now applied with the `'server'` origin the state path already uses, which the local listener ignores, so the echo stops. The echo did carry one thing worth keeping: the receiving client holds the merged document, which the sender had not seen yet, so each receiver now sends a content-only message once the edits settle, debounced 500ms, and flushes a pending one when the editor is torn down. The backend accepts an update message with no `update` field for that case, where it previously raised and dropped the save. Replaying keystrokes at 120ms with a real Yjs document, socket messages fall 48.8% with two clients, 65.6% with three and 79.2% with five, with byte counts tracking the same on notes up to 50KB. Server-side update appends drop by a factor of the client count. The cost is one snapshot upload per receiving client per typing pause, which the server's own debounce then collapses into a single extra note write however many people are watching. A snapshot has to come from a client because the server cannot rebuild the markdown, HTML and JSON shape the note record stores. It also moves the merge 500ms later than the echo delivered it, so if two edits cross on the wire and every editor then loses its connection and closes inside that window, the note keeps what it was last sent and one of the two edits is lost. A client still connected at teardown flushes its pending snapshot, and any other editor left in the note closes the gap. --- backend/open_webui/socket/main.py | 37 ++++++++++--------- .../common/RichTextInput/Collaboration.ts | 31 +++++++++++++++- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index f68252489b..c060db6de9 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -840,27 +840,28 @@ async def yjs_document_update(sid, data): log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.') return - user_id = data.get('user_id', sid) + update = data.get('update') # List of bytes from frontend - update = data['update'] # List of bytes from frontend + if update: + user_id = data.get('user_id', sid) - await YDOC_MANAGER.append_to_updates( - document_id=document_id, - update=update, # Convert list of bytes to bytes - ) + await YDOC_MANAGER.append_to_updates( + document_id=document_id, + update=update, # Convert list of bytes to bytes + ) - # Broadcast update to all other users in the document - await sio.emit( - 'ydoc:document:update', - { - 'document_id': document_id, - 'user_id': user_id, - 'update': update, - 'socket_id': sid, # Add socket_id to match frontend filtering - }, - room=f'doc_{document_id}', - skip_sid=sid, - ) + # Broadcast update to all other users in the document + await sio.emit( + 'ydoc:document:update', + { + 'document_id': document_id, + 'user_id': user_id, + 'update': update, + 'socket_id': sid, # Add socket_id to match frontend filtering + }, + room=f'doc_{document_id}', + skip_sid=sid, + ) async def debounced_save(): await asyncio.sleep(0.5) diff --git a/src/lib/components/common/RichTextInput/Collaboration.ts b/src/lib/components/common/RichTextInput/Collaboration.ts index c923ddface..0496704342 100644 --- a/src/lib/components/common/RichTextInput/Collaboration.ts +++ b/src/lib/components/common/RichTextInput/Collaboration.ts @@ -45,6 +45,7 @@ export class SocketIOCollaborationProvider { private synced = false; private editor: Editor | null = null; private editorContentGetter: EditorContentGetter | null = null; + private contentSnapshotTimer: ReturnType | null = null; constructor( private readonly documentId: string, @@ -114,6 +115,18 @@ export class SocketIOCollaborationProvider { Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(doc)); } + // Send the merged content; the remote sender had not seen our edits yet. + private sendContentSnapshot() { + this.contentSnapshotTimer = null; + const getContent = this.editorContentGetter; + if (!this.isConnected || !getContent) return; + + this.socket.emit('ydoc:document:update', { + document_id: this.documentId, + data: { content: getContent() } + }); + } + private joinDocument() { if (!this.editor) return; @@ -141,7 +154,13 @@ export class SocketIOCollaborationProvider { if (data.document_id === this.documentId && data.socket_id !== this.socket.id) { try { const update = new Uint8Array(data.update); - Y.applyUpdate(this.doc, update); + // 'server' stops the local update listener sending this straight back out + Y.applyUpdate(this.doc, update, 'server'); + + if (this.contentSnapshotTimer) { + clearTimeout(this.contentSnapshotTimer); + } + this.contentSnapshotTimer = setTimeout(() => this.sendContentSnapshot(), 500); } catch (error) { console.error('Error applying Yjs update:', error); } @@ -229,6 +248,11 @@ export class SocketIOCollaborationProvider { } } }); + + if (this.contentSnapshotTimer) { + clearTimeout(this.contentSnapshotTimer); + this.contentSnapshotTimer = null; + } } }); @@ -273,6 +297,11 @@ export class SocketIOCollaborationProvider { this.socket.off('connect', this.onConnect); this.socket.off('disconnect', this.onDisconnect); + if (this.contentSnapshotTimer) { + clearTimeout(this.contentSnapshotTimer); + this.sendContentSnapshot(); + } + if (this.isConnected) { this.socket.emit('ydoc:document:leave', { document_id: this.documentId,