How does virtual DOM work in Vue.js?
Virtual DOM in Vue.js - a JavaScript object tree that mirrors the real DOM structure, letting Vue calculate the minimum set of actual DOM mutations needed after each data change.
Theory
TL;DR
- Think of sketching on paper before painting walls: you only repaint what changed.
- Vue compiles
<template>into arender()function that returns VNodes - plain JS objects withtype,props,children,key. - On reactive change, Vue runs
patch(oldVNode, newVNode)and calls browser APIs only for the diffs. - Virtual DOM uses more memory (~2x DOM size) and gives a slower initial render, but makes frequent updates much cheaper.
- Decision rule: interactive UIs with frequent state changes - use virtual DOM. Static pages - skip it entirely.
Quick example
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>Count: {{ count }}</p> <!-- VNode created here -->
<button @click="count++">+1</button>
</template>Click the button: Vue creates a new VNode tree, diffs it against the previous one, and patches only the text node inside <p>. The <button> VNode is identical to the previous one and gets skipped completely.
Virtual DOM vs direct DOM
Direct DOM writes (element.innerHTML = ...) trigger full reflows and repaints on every assignment. Vue batches all reactive changes into a single diff pass, computes the exact mutations needed (e.g., a textContent swap), and applies them in one go via patch(). For a page updating ten times per second, that difference is visible.
How Vue builds the virtual tree
Vue's compiler transforms <template> into a render() function. That function returns a tree of VNodes - plain JS objects with fields type, props, children, and key. When reactive state changes (a ref() setter fires), the scheduler queues the component's render effect. On the next tick it re-runs render(), gets a new VNode tree, and hands both trees to patch().
V8 optimizes traversal of these objects through hidden classes, so the diff itself is fast. The browser's createElement() and setAttribute() get called only where the two trees differ.
The patch algorithm
patch(oldVNode, newVNode) walks both trees recursively with a few heuristics. Same tag and same key? Update props and recurse into children. Different tag? Unmount old, mount new. For sibling lists, Vue uses a longest-increasing-subsequence algorithm to find the minimum number of moves. That is why :key matters so much on v-for: without it, Vue falls back to index-based comparison, which produces wrong results on insertions and deletions.
shapeFlag bitmasks (e.g., 1=Element, 4=Text, 6=Fragment) let Vue branch in O(1) instead of checking types with instanceof. patchFlags go further: the compiler injects flags like TEXT=1 or PROPS=16 so the runtime skips full child recursion when only text changed. In production builds that gives roughly 90% faster updates compared to a naive full diff.
Batching and nextTick
Vue does not patch after every single reactive write. It queues jobs and flushes them in a microtask via Promise.resolve().then(). That is what nextTick() hooks into. Group ten state mutations together and you get one patch pass, not ten. I have seen code that called nextTick inside a for loop expecting sequential DOM reads - it does not work that way. One flush per tick.
Common mistakes
Mistake: using :key on non-list nodes
<!-- Wrong: forces full unmount/remount on every count++ -->
<div :key="count">Static text</div>
<!-- Right: omit key for stable nodes -->
<div>Static text</div>Every count++ destroys and recreates the element, losing focus state and breaking CSS transitions.
Mistake: mutating props directly in a child component
<script setup>
const props = defineProps(['item'])
props.item.done = true // Wrong: bypasses reactivity
</script>Vue diffs the parent's array shallowly. Direct mutation does not fire a reactive setter, so no new VNode is created and no patch runs. Emit an event up instead: emit('update:item', ...).
Mistake: using array index as key
<!-- Wrong: index shifts on insert/delete -->
<li v-for="(todo, i) in todos" :key="i">
<!-- Right: stable unique id -->
<li v-for="todo in todos" :key="todo.id">Insert at position 0 and every index shifts by one. Vue sees all keys changed and re-renders the whole list, not just the new item.
Mistake: wrong expectations from Teleport
<Teleport to="body"> moves VNodes to a different part of the real DOM, but the diff still runs in the component tree. If the target element does not exist when the component mounts, Vue 3.2+ logs a warning and the teleport silently fails. Always verify the target is mounted first.
Real-world usage
- Vue/Nuxt TodoMVC:
v-for :keydiffs 1000+ items at 60fps without visible lag. - Quasar dynamic forms: conditional VNodes via
v-ifskip rendering unused fields entirely. - Element Plus tables: keyed rows let Vue reorder columns in O(n) instead of rebuilding the whole table.
- Pinia-driven lists: store batch mutations flush as one patch pass, not one per mutation.
Follow-up questions
Q: How does Vue diff sibling VNodes without keys?
A: It assumes same order and patches by index. Insert at position 0 and every node after it gets patched unnecessarily. This is exactly why keyed diffing exists.
Q: What are patchFlags and why do they matter?
A: The compiler injects integer flags on VNodes at build time. The runtime checks the flag before deciding what to diff. TEXT=1 means only text changed, so Vue skips props comparison entirely. In real apps this cuts diff time by around 90% versus a full recursive walk.
Q: Why does nextTick() return a Promise?
A: Vue flushes queued patch jobs in a microtask. nextTick() returns a Promise that resolves after that flush, so you can await nextTick() to read updated DOM values without race conditions.
Q: How does SSR handle the virtual DOM?
A: renderToString() serializes VNodes to an HTML string. No diff runs on the server. The client receives that HTML and hydrates it by patching the server-rendered nodes in place rather than replacing them.
Q: How does Vue's diff compare to React's Fiber reconciler?
A: Vue runs a synchronous single-pass diff. React's Fiber is resumable - it can pause mid-reconciliation and yield to higher-priority work. Vue's approach is simpler and faster for most cases; React's handles very large trees where scheduling matters.
Examples
Basic: counter with a single reactive node
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<p>Count: {{ count }}</p>
<button @click="count++">+1</button>
</template>After each click Vue creates a new VNode for <p>. The diff finds only the text node changed and calls node.textContent = newCount. The button VNode is identical to the previous and gets skipped completely.
Intermediate: todo list with key-based diffing
<script setup>
import { ref, computed } from 'vue'
const todos = ref([
{ id: 1, text: 'Buy milk', done: false },
{ id: 2, text: 'Write code', done: true }
])
const activeCount = computed(() => todos.value.filter(t => !t.done).length)
const toggle = (id) => {
const todo = todos.value.find(t => t.id === id)
todo.done = !todo.done // Triggers diff on the list
}
</script>
<template>
<ul>
<li v-for="todo in todos" :key="todo.id" @click="toggle(todo.id)">
{{ todo.text }} <span v-if="todo.done">(done)</span>
</li>
</ul>
<p>{{ activeCount }} active</p>
</template>Toggle an item: Vue diffs the list by key, patches only the clicked <li> (adds or removes the <span>), and recomputes the activeCount VNode. Every other list item is untouched. Without :key="todo.id", Vue would re-render all <li> elements on any array change.
Advanced: Teleport and cross-boundary patching
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script>
<template>
<button @click="showModal = true">Open</button>
<Teleport to="body">
<div v-if="showModal" class="modal" @click="showModal = false">
Click to close
</div>
</Teleport>
</template>When showModal flips to true, Vue patches <body> directly via the Teleport VNode flag, not the component's root. The diff still runs in component tree order, but the DOM mutation lands on a completely different node. If body is not available when the component mounts (e.g., during SSR), Vue 3.2+ logs a warning. This is the clearest example that virtual DOM positions and real DOM positions do not have to match.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.