Chapter 8: ID #
In Item / AbstractStruct, we looked at the bricks that make up a Yjs document. Those bricks live on shelves in the StructStore. But how does Yjs tell one brick from another? When two people are editing offline, how does it know which change came from which device, and in what order?
The answer is a tiny but powerful concept: the ID.
What problem does ID solve? #
Alice and Bob are using a shared grocery list. They are offline for a while.
Alice adds "milk" on her laptop:
const aliceDoc = new Y.Doc()
const aliceList = aliceDoc.getArray('grocery')
aliceList.push(['milk'])
Bob adds "eggs" on his phone:
const bobDoc = new Y.Doc()
const bobList = bobDoc.getArray('grocery')
bobList.push(['eggs'])
Later, they reconnect and send updates to each other. What should happen?
Both lists should merge. Alice should see "milk" and "eggs". Bob should see the same. But how does Yjs know that "milk" and "eggs" are two different items, and not two versions of the same item?
Yjs knows because every item carries an ID. Alice’s item has an ID that says “this was created by Alice’s device, and it was the first thing she created”. Bob’s item has an ID that says “this was created by Bob’s device, and it was the first thing he created”. The IDs are different, so Yjs keeps both items.
This is like a library call number: it tells you which collection a book came from and exactly where it belongs on the shelf. In Yjs, an ID tells you who created a struct and where it fits in that person’s history.
Key concept: an ID has two halves #
An ID looks like this:
{
client: 734211,
clock: 0
}
The two parts are:
| Part | Meaning |
|---|---|
client | Which device or user created the struct |
clock | The ordering number for that client |
A client is just a random number that a
Doc gets when it is created. It is like a device signature.
A clock starts at 0 and increases each time that client creates a new struct. It is a logical order number, not a timestamp.
You can inspect an item’s ID for learning purposes:
const doc = new Y.Doc()
doc.getArray('grocery').push(['milk'])
const itemId = doc.store.clients.get(doc.clientID)[0].id
console.log(itemId.client === doc.clientID) // true
console.log(itemId.clock) // 0
This is only for exploring internals. In a real app, you don’t need to touch item IDs directly. Yjs does it for you.
Key concept: the clock is logical, not a timestamp #
The clock is not the time of day. It is a counter that advances by the length of each struct.
For example, if you insert the text "Hi", that struct has length 2:
const doc = new Y.Doc()
doc.getText('note').insert(0, 'Hi')
const struct = doc.store.clients.get(doc.clientID)[0]
console.log(struct.id.clock) // 0
console.log(struct.length) // 2
Because that struct uses clock range 0 and 1, the next struct created by the same client would start at clock 2.
This is helpful because Yjs can talk about entire ranges of clocks at once. Instead of saying “I have clock 0, clock 1, clock 2, clock 3”, it can say “I have clocks 0 to 3”.
Key concept: IDs are unique #
Why are IDs unique? Because no two client IDs are the same, and within one client, clocks never repeat.
Imagine two people both have clock: 0:
- Alice’s item ID:
{ client: 111, clock: 0 } - Bob’s item ID:
{ client: 222, clock: 0 }
These are different IDs because the clients are different.
Now imagine one person creates two items on the same device:
- First item ID:
{ client: 111, clock: 0 } - Second item ID:
{ client: 111, clock: 1 }
These are different IDs because the clocks are different.
So every struct in Yjs gets a globally unique ID without needing a central server. This is what makes offline editing possible.
A simple way to compare two IDs is:
function sameId(a, b) {
return a.client === b.client && a.clock === b.clock
}
Two IDs are the same only when both parts match.
How IDs solve the grocery list problem #
Let’s see what happens when Alice and Bob sync.
Alice’s "milk" item has an ID like:
{ client: aliceDoc.clientID, clock: 0 }
Bob’s "eggs" item has an ID like:
{ client: bobDoc.clientID, clock: 0 }
When Bob’s update reaches Alice, Yjs decodes Bob’s item and looks at its ID. Alice’s
StructStore has an item from Alice’s client at clock 0, but it has no item from Bob’s client at clock 0. So Yjs treats Bob’s item as brand new and inserts it.
Alice’s list now has both items:
Y.applyUpdate(aliceDoc, Y.encodeStateAsUpdate(bobDoc))
console.log(aliceList.length) // 2
console.log(aliceList.toArray()) // ['milk', 'eggs']
What if Bob’s update is sent twice?
The second time, Alice’s StructStore sees that an item with Bob’s ID already exists. It knows this is a duplicate update, so it does nothing:
Y.applyUpdate(aliceDoc, bobUpdate)
Y.applyUpdate(aliceDoc, bobUpdate) // duplicate! ignored
console.log(aliceList.length) // still 2
This is how IDs help Yjs merge edits and detect duplicates.
Under the hood: the ID class #
The real ID class lives in src/utils/ID.js. It is intentionally tiny.
// simplified from src/utils/ID.js
export class ID {
constructor(client, clock) {
this.client = client
this.clock = clock
}
}
That’s basically it. An ID is just an address card with two numbers.
Yjs also has a helper to compare IDs:
// simplified from src/utils/ID.js
export const compareIDs = (a, b) =>
a.client === b.client && a.clock === b.clock
The real version handles some edge cases, but this is the core idea.
What happens when an ID is stored or sent? #
When you insert content, Yjs creates an ID inside a Transaction, then stores the struct in the StructStore.
When Yjs sends an update, it writes the ID into the update bytes. When another peer receives the update, it reads the ID back out.
Writing an ID:
// simplified from src/utils/UpdateEncoder.js
const writeID = (encoder, id) => {
encoder.writeVarUint(id.client)
encoder.writeVarUint(id.clock)
}
Reading an ID:
// simplified from src/utils/UpdateDecoder.js
const readID = (decoder) => {
const client = decoder.readVarUint()
const clock = decoder.readVarUint()
return new ID(client, clock)
}
Every struct in an update starts with its ID. That is how the receiving document knows where the struct belongs.
Here is the journey of Alice’s "milk" item:
Step by step:
- Alice’s document starts a transaction.
- The transaction creates an ID for the new item.
- The item is stored in Alice’s
StructStore. - Alice’s update encodes the ID.
- Bob’s document decodes the ID.
- Bob’s
StructStorechecks if that ID already exists. - If it does not exist, the struct is inserted.
Checking for duplicate IDs in StructStore #
The StructStore keeps structs sorted by client and clock. When a struct arrives, Yjs finds the correct position for its ID. If an item with that exact ID is already there, Yjs knows it has seen this update before.
Here is a very simplified version:
// very simplified from src/utils/StructStore.js
function integrate(store, struct) {
const shelf = store.clients.get(struct.id.client) || []
const pos = lowerBound(shelf, struct.id.clock)
if (shelf[pos] && shelf[pos].id.clock === struct.id.clock) {
return // duplicate ID: already stored
}
shelf.splice(pos, 0, struct)
}
lowerBound is a helper that finds the first position where the new struct should go. This is why the StructStore is fast: it doesn’t scan every struct. It uses the ID to jump to the right place.
ID sets and type declarations #
Sometimes Yjs needs to talk about many IDs at once, not just one. For example, it might need to say:
“I already have all structs from client 7 from clock 0 to clock 99.”
This is called an ID range. Yjs also has helpers like IdRange, IdSet, and IdMap to manage collections of IDs compactly.
In global.d.ts, these types are declared like this:
declare type ID = import('./src/utils/ID.js').ID
declare type IdRange = import('./src/utils/ids.js').IdRange
declare type IdSet = import('./src/utils/ids.js').IdSet
These type declarations don’t change how Yjs runs. They just give your editor useful information. You don’t need to use these directly, but it helps to know they exist.
Conclusion #
Every struct in Yjs carries a tiny ID with two parts:
client— who created itclock— where it belongs in that client’s history
IDs are the reason Yjs can merge offline edits, sort structs correctly, ignore duplicate updates, and detect conflicting ones. They are the invisible address labels on every piece of collaborative data.
You have now completed the beginner tour of Yjs internals. You met the Doc, the YType, the Transaction, Update Encoding / Decoding, Snapshot, StructStore, Item / AbstractStruct, and finally the ID.
You don’t need to memorize every internal detail to build apps with Yjs. But now, when you see an update, a snapshot, or a clientID, you know exactly what is happening under the hood. And when something surprising happens, you can follow the IDs!
Generated by AI Codebase Knowledge Builder