Vue.js 100 🟢 Vue Core Internals: Deep Dive into Mount Phase, Render Pipeline, and Scheduler Queue
The mount phase, the render pipeline, and the scheduler queue are three stages of the same mechanism. The mount phase creates the component instance, runs the setup, and executes the render function for the first time. The render pipeline turns the template into a virtual DOM tree and patches it into real DOM nodes. The scheduler queue collects the reactive effects that need to re-run and flushes them in a single batch, in the correct order, at the microtask boundary. Each stage has a specific entry point in the runtime, and each is responsible for a distinct part of the component’s lifecycle.
This chapter traces the three stages from the internal functions that implement them: mountComponent, setupRenderEffect, and queueJob/flushJobs. It examines the effect that wraps the render function, the distinction between the initial mount and the update path inside the same effect, and the queue that batches updates and guarantees parent-before-child execution order.
Key point: The render function is not called directly. It is wrapped in a reactive effect, and the effect’s scheduler is queueJob. When a reactive dependency changes, the effect does not re-run immediately; it is pushed into the scheduler queue and flushed as a microtask. The queue is sorted by component UID, which is why parent components update before their children.
Why these internals matter
The effect wrapper problem. A component’s render function is executed inside a ReactiveEffect. The first run of the effect is the mount. The subsequent runs are the updates. The effect is what makes the render reactive: it tracks every reactive property that the render function reads, and when one of them changes, the effect is triggered . Understanding this wrapper explains why a component re-renders when its state changes and why the render function must be pure.
The scheduler batching problem. If a reactive effect re-ran immediately on every trigger, a component that updates three state variables in one handler would render three times. The scheduler queue exists to batch these triggers: the effect is pushed into a queue, and the queue is flushed once in a microtask. The effect runs once, not three times, for the same tick . The nextTick function returns the promise that resolves after the flush, which is why await nextTick() guarantees that the DOM is updated.
The ordering problem. The queue is sorted by the component’s uid before flushing. The uid is assigned incrementally when the component instance is created, and the parent is always created before the child. Sorting by uid guarantees that parent components update before their children, which is necessary because a child’s props may depend on the parent’s new state . Without the sort, a child could render with stale props from a parent that has not yet updated.
a. The Mount Phase: createComponentInstance and setupRenderEffect
The mountComponent function is the entry point for mounting a component. It performs three operations: creating the component instance, setting up the instance, and creating the render effect .
// Conceptual mountComponent flow
const instance = (initialVNode.component = createComponentInstance(
initialVNode, parentComponent, parentSuspense
))
setupComponent(instance)
setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense)
The createComponentInstance function creates an object that holds the component’s state: props, attrs, slots, setupState, subTree, and isMounted. The setupComponent function runs the component’s setup() function and initializes the props and slots. The setupRenderEffect function creates the reactive effect that wraps the render function .
// Conceptual setupRenderEffect
const setupRenderEffect = (instance, initialVNode, container, ...) => {
instance.update = effect(function componentEffect() {
if (!instance.isMounted) {
// MOUNT: render the component's subtree
const subTree = (instance.subTree = renderComponentRoot(instance))
// Patch the subtree into the container
patch(null, subTree, container, anchor, instance, parentSuspense)
// Store the root DOM node
initialVNode.el = subTree.el
instance.isMounted = true
} else {
// UPDATE: re-render and patch
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
patch(prevTree, nextTree, ...)
}
}, prodEffectOptions)
}
The componentEffect function is the effect’s runner. On the first run, instance.isMounted is false, and the function executes the mount path: it calls renderComponentRoot to execute the render function and produce the subtree VNode, then calls patch with null as the old VNode to mount the subtree into the container . After the mount, isMounted is set to true, and subsequent runs of the effect take the update path.
The prodEffectOptions object contains the scheduler. In production, the scheduler is queueJob, which means that when a reactive dependency changes, the effect is not run immediately; it is pushed into the scheduler queue.
b. The Render Pipeline: renderComponentRoot and patch
The render function is not the only step in the pipeline. The renderComponentRoot function executes the component’s render function, normalizes the result, and returns the subtree VNode. The patch function then compares the subtree to the previous one and applies the differences to the DOM.
The render function can be the compiled template, a manually written render function, or the function returned by setup(). The renderComponentRoot function calls the appropriate function and handles the result. If the result is an array, it wraps it in a Fragment VNode. If the result is a single VNode, it returns it directly.
The patch function is the recursive diffing function. On the initial mount, patch(null, subTree, ...) is called, which means there is no old VNode to compare against. The function dispatches on the VNode’s type: an element VNode goes to processElement, a component VNode goes to processComponent, a text VNode goes to processText, and so on . Each processor either mounts the node (when n1 is null) or updates it (when n1 and n2 are both present).
The render pipeline is compiler-informed. The template compiler emits patchFlag hints on dynamic VNodes, and the patchElement function reads these flags to update only the properties that can change. A static element with no patchFlag is skipped entirely during the patch phase . This is why the compiled template is faster than a manually written render function that creates new VNodes without the flags.
c. The Scheduler Queue: queueJob and flushJobs
The scheduler queue is the mechanism that batches updates. The queueJob function pushes the component’s update effect into the queue and schedules a flush. The flushJobs function sorts the queue by component uid, runs each job, and resets the state.
// Conceptual scheduler implementation
const queue = []
let isFlushing = false
let isFlushPending = false
let currentFlushPromise = null
const resolvedPromise = Promise.resolve()
function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job)
}
queueFlush()
}
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
function flushJobs() {
isFlushing = true
isFlushPending = false
queue.sort((a, b) => a.id - b.id)
for (const job of queue) {
job()
}
queue.length = 0
isFlushing = false
currentFlushPromise = null
}
The queue.includes(job) check prevents the same job from being pushed twice in the same flush. If a component’s state changes three times in one handler, the effect is triggered three times, but queueJob is called with the same job each time, and the includes check ensures that only one copy is in the queue . The effect runs once.
The isFlushPending flag prevents multiple microtasks from being scheduled. The first queueJob call sets the flag and schedules the flush. Subsequent calls in the same tick see the flag and do not schedule another microtask. The flush runs once.
The queue.sort((a, b) => a.id - b.id) is what guarantees parent-before-child execution. The component’s uid is assigned incrementally when createComponentInstance runs, and the parent is always created before its children. A smaller uid means the component was created earlier, which means it is higher in the tree. Sorting by uid ensures that the parent’s update effect runs before the child’s .
The nextTick function returns the currentFlushPromise if a flush is pending, or the resolved promise otherwise. Awaiting it guarantees that the flush has completed and the DOM is updated .
Complete Example Session
// ============================================
// PART 1: MOUNT COMPONENT
// ============================================
// Entry point for mounting a component
// 1. createComponentInstance
// 2. setupComponent (runs setup())
// 3. setupRenderEffect (creates the render effect)
// ============================================
// PART 2: SETUP RENDER EFFECT
// ============================================
// The effect wraps componentEffect
// First run: mount path
// Subsequent runs: update path
// ============================================
// PART 3: MOUNT PATH INSIDE THE EFFECT
// ============================================
// if (!instance.isMounted) {
// const subTree = renderComponentRoot(instance)
// patch(null, subTree, container, ...)
// instance.isMounted = true
// }
// ============================================
// PART 4: UPDATE PATH INSIDE THE EFFECT
// ============================================
// else {
// const nextTree = renderComponentRoot(instance)
// const prevTree = instance.subTree
// patch(prevTree, nextTree, ...)
// }
// ============================================
// PART 5: THE SCHEDULER OPTION
// ============================================
// prodEffectOptions = { scheduler: queueJob }
// When a reactive dependency changes, the effect is not run
// queueJob pushes the effect into the scheduler queue
// ============================================
// PART 6: QUEUE JOB DEDUPLICATION
// ============================================
// queue.includes(job) prevents duplicate entries
// Three state changes in one handler → one effect run
// ============================================
// PART 7: FLUSH JOBS SORTING
// ============================================
// queue.sort((a, b) => a.id - b.id)
// Parent (smaller uid) runs before child (larger uid)
// ============================================
// PART 8: NEXT TICK
// ============================================
// await nextTick() waits for the flush to complete
// Guarantees the DOM is updated
The eight parts trace the mount entry, the effect creation, the two paths inside the effect, the scheduler option, the deduplication, the sorting, and the nextTick guarantee.
Quick Reference
Mount Phase Functions
| Function | Purpose |
|---|---|
mountComponent | Entry point for mounting |
createComponentInstance | Creates the instance object |
setupComponent | Runs setup(), initializes props/slots |
setupRenderEffect | Creates the reactive render effect |
The Effect Paths
| Path | Condition | Action |
|---|---|---|
| Mount | !instance.isMounted | Render subtree, patch(null, subTree) |
| Update | instance.isMounted | Render new subtree, patch(prevTree, nextTree) |
Scheduler Functions
| Function | Purpose |
|---|---|
queueJob(job) | Push effect into queue, schedule flush |
queueFlush() | Schedule the microtask flush |
flushJobs() | Sort queue, run jobs, reset state |
nextTick() | Wait for flush to complete |
Ordering Guarantee
| Mechanism | Effect |
|---|---|
uid assignment | Incremental, parent before child |
queue.sort((a, b) => a.id - b.id) | Parent updates before child |
Best Practices
✅ Do This:
// Use nextTick to wait for DOM updates
await nextTick()
console.log(element.textContent) // ✅
// Keep render functions pure; side effects belong in effects
function render() { return h('div', count.value) } // ✅
// Batch state updates in the same handler
function handleClick() {
count.value++
flag.value = true
} // ✅
❌ Don’t Do This:
// Assume the DOM is updated synchronously after a state change
count.value++
console.log(element.textContent) // stale // ❌
// Create effects after await in nested callbacks
fetch().then(() => watchEffect(() => {})) // orphaned // ❌
// Assume the scheduler runs on a macrotask
setTimeout(() => {}, 0) // flush happens before this // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| DOM is stale after state change | Scheduler flushes in microtask | await nextTick() |
| Child renders with stale props | Parent not updated first | Queue sorted by uid handles this |
| Effect runs multiple times | Duplicate jobs in queue | queue.includes deduplication |
| Infinite update loop | Effect triggers itself | checkRecursiveUpdates warning |
Real-World Examples
1. Mount Entry
const instance = createComponentInstance(vnode, parent)
setupComponent(instance)
setupRenderEffect(instance, vnode, container)
2. Effect Creation
instance.update = effect(componentEffect, { scheduler: queueJob })
3. Mount Path
const subTree = renderComponentRoot(instance)
patch(null, subTree, container)
instance.isMounted = true
4. Update Path
const nextTree = renderComponentRoot(instance)
patch(instance.subTree, nextTree)
instance.subTree = nextTree
5. Queue Job
queue.push(job)
queueFlush()
6. Flush Jobs
queue.sort((a, b) => a.id - b.id)
queue.forEach(job => job())
7. Next Tick
await nextTick()
8. UID Assignment
instance.uid = uid++
9. Deduplication
if (!queue.includes(job)) queue.push(job)
10. Parent Before Child
// uid 1 (parent) runs before uid 2 (child)
Visual
The Mount Phase
┌──────────────────────────────────────────────────────────────┐
│ mountComponent │
│ │ │
│ ├── createComponentInstance │
│ ├── setupComponent (runs setup()) │
│ └── setupRenderEffect │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ effect( │ │
│ │ componentEffect, │ │
│ │ { scheduler: │ │
│ │ queueJob } │ │
│ │ ) │ │
│ └─────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ componentEffect() │ First run: mount │
│ │ renderComponentRoot│ │
│ │ patch(null, subTree)│ │
│ │ isMounted = true │ │
│ └─────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Scheduler Queue
┌──────────────────────────────────────────────────────────────┐
│ STATE CHANGE │
│ │ │
│ ▼ │
│ effect triggered │
│ │ │
│ ▼ │
│ scheduler: queueJob(effect) │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ queue = [job] │ │
│ │ queueFlush() │ │
│ └─────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ resolvedPromise │ Microtask │
│ │ .then(flushJobs) │ │
│ └─────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ flushJobs() │ │
│ │ sort by uid │ │
│ │ run each job │ │
│ └─────────────────────┘ │
│ │
│ nextTick() returns the flush promise. │
│ │
└──────────────────────────────────────────────────────────────┘
Parent Before Child
┌──────────────────────────────────────────────────────────────┐
│ QUEUE BEFORE SORT │
│ [childJob(uid=2), parentJob(uid=1)] │
│ │
│ queue.sort((a, b) => a.id - b.id) │
│ │
│ QUEUE AFTER SORT │
│ [parentJob(uid=1), childJob(uid=2)] │
│ │
│ Parent renders first. │
│ Child renders with the parent's new props. │
│ │
│ Without the sort, the child would render with stale props. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Mount entry | mountComponent |
| Instance creation | createComponentInstance |
| Render effect | setupRenderEffect → effect(componentEffect) |
| Mount path | !isMounted → renderComponentRoot + patch(null, subTree) |
| Update path | isMounted → patch(prevTree, nextTree) |
| Scheduler option | { scheduler: queueJob } |
| Queue deduplication | queue.includes(job) |
| Flush ordering | queue.sort((a, b) => a.id - b.id) |
| Next tick | Returns the flush promise |
| Microtask | Promise.resolve().then(flushJobs) |
Key takeaways:
- The render function is wrapped in a reactive effect. The effect is created in
setupRenderEffect, and its first run is the mount. Subsequent runs are the updates. The effect tracks every reactive property that the render function reads, and when one changes, the effect is triggered . - The mount path and the update path are two branches of the same effect. The
componentEffectfunction checks!instance.isMountedto decide whether to mount or update. The mount path callspatch(null, subTree), and the update path callspatch(prevTree, nextTree). - The scheduler batches updates into a single flush. The
queueJobfunction pushes the effect into a queue and schedules a flush. Thequeue.includes(job)check prevents duplicate entries, so multiple state changes in one tick produce one render . - The queue is sorted by component
uid. Theuidis assigned incrementally when the component instance is created. Sorting byuidguarantees that parent components update before their children, which is necessary for correct prop propagation . nextTickreturns the flush promise. Awaiting it guarantees that the scheduler has finished running the queued jobs and the DOM has been updated. This is the mechanism that makesawait nextTick()a reliable way to read the DOM after a state change .
Remember: The mount phase, the render pipeline, and the scheduler queue are three views of the same process. The mount phase creates the effect that drives the render pipeline. The render pipeline produces the VNode tree and patches it into the DOM. The scheduler queue decides when the effect re-runs and in what order. The mountComponent function is the entry point, setupRenderEffect is the bridge between the component instance and the reactive system, and queueJob/flushJobs are the batching mechanism that makes Vue’s updates efficient and correctly ordered. The three stages are not independent; the effect created in the mount phase is the job that the scheduler queues, and the render function executed by the effect is the pipeline that produces the DOM updates.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!