Skip to main content
  1. Yjs Internals/

Chapter 3: Transaction #

Welcome back! In the previous chapter, YType, we saw how shared types like Y.Map and Y.Text capture every change. But we didn’t talk about when those changes are announced. If you change two things at once, should your app update twice? No. Yjs uses a Transaction to bundle changes together into one neat package.

What problem does Transaction solve? #

Imagine you’re building a collaborative profile card. A Y.Map holds the profile:

const profile = doc.getMap('profile')
profile.set('name', 'Ada')
profile.set('title', 'Engineer')

If you run those two lines one after another, Yjs could theoretically notify your app twice: first with a card that says “Ada” and no title, then with the full card. That would make the screen flicker. It would also send two network messages. Instead, Yjs wraps both changes in a single transaction. Observers are told only once, after both changes are done. The result feels like the profile changed from “old Ada” to “new Ada” in one single moment.

Key concept: a Transaction is a batch #

Think of a transaction as a mixing bowl in a kitchen. You don’t serve flour, eggs, and milk separately. You mix them all in the bowl first, then you serve the cake. Similarly, a transaction collects related changes, and only when the batch is complete does Yjs announce the result.

Here’s what a transaction gives you:

  • Atomicity: From the outside, the changes happen at the same time. You never see a half-finished state.
  • One update message: When the transaction finishes, it produces a single Uint8Array update that describes all the changes.
  • A simple origin label: You can attach a reason to the batch, like 'local-user' or 'remote-change'.

How to use a Transaction #

You don’t usually create a Transaction object directly. You use doc.transact(fn, origin) from the Doc.

Step 1: Group multiple changes #

Let’s update two fields at once:

doc.transact(() => {
  profile.set('name', 'Grace')
  profile.set('title', 'Mathematician')
})

Both set calls happen in the same batch.

Step 2: Observe what happened #

Let’s attach an observer to see how many times the profile changes:

let changes = 0

profile.observe(() => {
  changes++
  console.log('change', changes, profile.toJSON())
})

Now run a grouped transaction with two set calls:

doc.transact(() => {
  profile.set('name', 'Ada')
  profile.set('title', 'Engineer')
})

You will see this output:

change 1 { name: 'Ada', title: 'Engineer' }

The observer ran once, not twice. The two changes were announced together.

Step 3: See what happens without a transaction #

Now reset the counter and make two separate changes:

changes = 0

profile.set('name', 'Katherine')
profile.set('title', 'Physicist')

This time you’ll see:

change 1 { name: 'Katherine', title: 'Engineer' }
change 2 { name: 'Katherine', title: 'Physicist' }

Observers were notified twice. Each set call is its own tiny transaction. That’s the difference: without a transaction, each change is announced separately; with a transaction, they’re announced together.

Step 4: Add an origin #

You can pass an origin as the second argument to doc.transact. This is a label that explains where the changes came from:

doc.transact(() => {
  profile.set('name', 'Ada')
}, 'my-app')

You can read it in the update listener:

doc.on('update', (update, origin) => {
  console.log(origin) // 'my-app'
})

The origin is useful when you want to ignore changes that you made yourself. For example, in a chat app, you might only fetch new content when origin is a remote peer.

What’s under the hood? #

When you call doc.transact, several things happen in order. Let’s walk through them with a small example.

Suppose you run:

doc.transact(() => {
  profile.set('name', 'Ada')
  profile.set('title', 'Engineer')
})

Here is a picture of the process:

sequenceDiagram participant You participant Doc participant T as Transaction participant Store as StructStore participant Observer You->>Doc: doc.transact(function) Doc->>T: create Transaction You->>T: profile.set("name", "Ada") T->>Store: add Item for "Ada" You->>T: profile.set("title", "Engineer") T->>Store: add Item for "Engineer" You-->>T: function returns T->>Doc: finish() Doc->>Observer: notify once Doc-->>You: done

Let’s break it down:

  1. You call doc.transact(fn).
  2. The Doc checks if a transaction is already active. If not, it creates a new Transaction.
  3. Your function runs.
  4. Each time you call profile.set, Yjs creates a small struct called an Item and places it in the StructStore, the warehouse where all data lives.
  5. After your function returns, the transaction calls finish().
  6. finish() encodes the new structs into one update, emits the update event, and tells all observers about the changes.
  7. The transaction is closed.

Simplified code from Doc #

Here’s a simplified version of doc.transact based on the real code in src/utils/Doc.js:

// simplified from src/utils/Doc.js
transact(f, origin) {
  if (this._transaction) {
    return f() // already inside a transaction
  }
  const t = new Transaction(this, origin)
  try {
    return f()
  } finally {
    t.finish()
  }
}

Notice the if (this._transaction) check. It means that if you’re already inside a transaction, a nested doc.transact doesn’t create a new one. It just runs your function inside the existing transaction. This is useful when different parts of your app each call transact, but they end up sharing one batch.

Simplified code from Transaction #

The real Transaction class lives in src/utils/Transaction.js. Here is a very simplified version:

// simplified from src/utils/Transaction.js
class Transaction {
  constructor(doc, origin) {
    this.doc = doc
    this.origin = origin
    doc._transaction = this
  }

  finish() {
    // collect all new structs and encode them into one update
    const update = this.encodeNewStructs()
    // tell the Doc about the update
    this.doc.emit('update', [update, this.origin, this.doc])
    // close the transaction
    this.doc._transaction = null
    // notify all shared type observers
    for (const event of this.changedEvents) {
      event.target.emit('change', event)
    }
  }
}

We left out a lot of important details, but this is the main idea. The transaction is the thing that:

  • keeps a reference to the Doc
  • remembers the origin
  • tracks what structs were added or removed
  • produces a single update
  • notifies all observers

The encodeNewStructs() function in the real code is much more clever, but for now you can think of it as “turn all the changes from this transaction into one compact binary message.”

What about applying remote updates? #

Remote updates are also applied inside a transaction. When you call:

Y.applyUpdate(doc, update)

Yjs opens a transaction, applies all the structs from the incoming update, and then finishes. This means:

  • Observers don’t see partially applied remote data.
  • The update event fires once with a label you can use.

So even if update contains 200 changes from a friend, your app sees one clean batch. This is one of the reasons Yjs feels smooth during sync.

Nested transactions are your friend #

Sometimes you might have helpers that each call doc.transact. Here is an example where a helper is called inside an outer transaction:

function setFullName(doc, profile, name) {
  doc.transact(() => {
    const parts = name.split(' ')
    profile.set('firstName', parts[0])
    profile.set('lastName', parts[1])
  })
}

doc.transact(() => {
  setFullName(doc, profile, 'Grace Hopper')
  profile.set('rank', 'Rear Admiral')
})

Only one transaction is used for all three set calls. The inner transact sees that doc._transaction already exists, so it simply runs inside the outer transaction. The result is one update event with all three changes.

Conclusion #

Transactions are the quiet worker behind every Yjs change.

  • A transaction is a batch of changes applied atomically.
  • Use doc.transact(fn, origin) to group multiple local changes.
  • Shared type observers and doc.on('update') fire only after the transaction finishes.
  • Remote updates are applied inside a transaction too.
  • Nested transact calls merge into the current transaction.

Now that you know how changes are grouped in a transaction, the next natural question is: how do those changes get packed into a message? That’s exactly what Update Encoding / Decoding is about.


Generated by AI Codebase Knowledge Builder