跳至主要内容

[Google Docs] Optimization - Binary encoding


Why we need Encoding?

Since in collaborative editing, there will be lots of updates, and there are 2 ways to send updates data:

  • JSON
  • Binary

typeJSONBinary
Readability
Efficiency


  • JSON is more readable, but terrible for efficiency, becauseJSON contains lots of redundant information, like:
    • curly braces {}
    • double quotes ""
    • commas ,
    • key name


  • And Binary is more efficient, we only need to send binary data, which is the same format when transfer through the network, and we don't need to add redundant information for readability.


How encoding works in Yjs and Lexical?

There are 5 steps to send updates data from Lexical to Yjs:

  1. Lexical JSON → Yjs struct (JS object)
  2. Yjs struct → lib0 encoding
  3. lib0 encoding → WebSocket
  4. WebSocket → lib0 decoding
  5. lib0 decoding → Yjs struct
  6. Yjs struct → Lexical JSON


Here we will use insert A as an example to show how encoding works in Yjs and Lexical.

1. Lexical Editor generated data

PhaseDetailExample
Lexical node treeEditor internal "DOM-like" tree structurejs\n{\n root: {\n type: 'root',\n children: [\n { type: 'paragraph', children: [ { type: 'text', text: 'A' } ] }\n ]\n }\n}\n
Lexical update payloadJSON difference when onUpdate is triggered (insert/delete/modify which node)js\n{\n mutations: [\n { op: 'insert_text', nodeKey: 'node#42', offset: 0, text: 'A' }\n ],\n selection: { anchor: 1, focus: 1 }\n}\n

These are still pure JSON / JS objects, large in size, and only for internal use by the frontend.



2. @lexical/yjs convert Lexical diff to Yjs Doc

In 2nd step, the @lexical/yjs will convert update JSON payload to Yjs struct object.


StepContentExample
Map to Yjs structparagraphY.XmlElement
text nodeY.XmlText
Y.XmlText content becomes "A"
Write to Y.Docdoc.transact(() => yText.insert(0, 'A'))Yjs internally creates a struct for clientId=1:
<1,clock=0,len=1,type=text,data='A'>


What does a Struct look like in JS?

{
id: { client: 1, clock: 0 },
length: 1, // 1 text unit
left: null, right: null, // inserted at the beginning
parent: yDoc.getText('t'), // belongs to Y.XmlText
parentSub: null,
content: { // ContentString
constructor: ContentString,
str: 'A'
}
}


3. Yjs generates binary update (lib0 encoding)

PhaseContentExample
encodeStateAsUpdateV2Compare peer's stateVector to find missing structsPeer has no data yet, so package everything
lib0 writes bytesUsing UpdateEncoderV2:
writeVarUint(clientId=1)
writeVarUint(clock=0)
writeVarString('A')
Get Uint8Array like:
[ 12, 1, 0, 65 ]
(actual will have header/CRC, shown here for illustration)

This is already extremely small binary, typically only 5-10% of the original JSON size.



How UpdateEncoderV2 writes Uint8Array

Yjs Update V2 serialization rules (highly simplified)

OrderWriteDescriptionValue in this example
writeVarUint(#clients)How many clients in this update1
writeVarUint(clientId)User 11
writeVarUint(#structs)How many new structs for this client1
info 1 bytebit-flags: has left/right/parentSub...0x00 (= none)
writeVarUint(clock)Starting clock of this struct0
writeVarUint(len)struct length1
writeVarString(parent type)0 → directly under root0
writeVarUint(contentType)4 represents string4
writeVarUint(str.length)11
UTF-8 bytes'A'0x410x41


Combined as 01 01 01 00 00 00 04 01 41 (10 bytes)

Variable-length integers (varUint) all use lib0's 7-bit continuation format:
0-127 → 1 byte, 128-16383 → 2 bytes ..., so all values fall in the 0x00-0x7F range.



How Yjs struct looks like in JS object & binary format?


LevelContentReadable Form
Yjs Object (JS)new Item(id, left, right, parent, parentSub, content, ...)⟨client 1, clock 0, len 1, content='A'⟩
Binary Stream (Uint8Array)Written by UpdateEncoderV2 using variable-length integers & flags01 01 01 00 00 00 04 01 41
(hex 01 01 01 00 00 00 04 01 41)

Top row is the "semantic" form we humans read; Bottom row is the actual bytes transmitted over the network (using the simplest 1 byte 'A' insertion as an example).


Code Demo: Try it yourself

import * as Y from 'yjs'

const doc = new Y.Doc()
doc.clientID = 1 // For demonstration, keep ID as 1
doc.getText('t').insert(0, 'A') // Insert one letter

const u8 = Y.encodeStateAsUpdateV2(doc) // Uint8Array
console.log([...u8]) // Might print [1,1,1,0,0,0,4,1,65]

When you send applyUpdateV2(peerDoc, u8) to another browser, it will reconstruct the same A, exactly demonstrating the round trip of "semantic struct → binary stream → semantic struct".



4. Transmit to server & other Peers

StepTransport LayerExample
WebSocket sendDirectly use socket.send(uint8Array) (Binary Frame)on-wire = same Uint8Array
Server relayOption A broadcast unchanged
Option B merge updates


5. Peer reconstruction

PhaseContentExample
applyUpdateV2(doc, uint8Array)Yjs parses binary, creates same structText "A" appears in peer's Y.XmlText
@lexical/yjs reflects to editorMap Yjs changes → Lexical commandPeer's Lexical editor.update(() => …), inserts A in paragraph


Summary

Complete flow: Lexical JSONYjs structlib0 encodingWebSocketlib0 decodingYjs structLexical JSON.

  1. Lexical handles UI
  2. @lexical/yjs writes diffs to Yjs
  3. Yjs only transmits Uint8Array diff packets
  4. Peers map back to Yjs struct → Lexical after receiving

This achieves in collaborative editing: semantic completeness ✕ minimal size ✕ peer reconstruction



References