Vue's Virtual DOM and Diff Algorithm: How PatchFlags and Static Hoisting Speed Up Rendering
A deep dive into how Vue 3's diff algorithm works under the hood — how the compiler tags dynamic nodes with PatchFlags, skips static content with static hoisting, and makes every update touch only what actually changed.
If you've ever compared rendering performance between Vue 2 and Vue 3, you've probably noticed something interesting: both rely on a virtual DOM and a diff algorithm, yet Vue 3 is noticeably faster — fast enough that the Vue team will straight-up tell you "the bigger the template, the bigger the win." This post digs into where that optimization actually lives, and how PatchFlags and static hoisting team up to make diffing genuinely smart instead of just fast.
First, what problem is the diff algorithm even solving?
The virtual DOM's core idea is simple: instead of touching the real DOM directly on every update (DOM operations are expensive), you build a new virtual DOM tree in memory, compare it against the old one, find the differences, and apply those differences to the real DOM in one batch.
The catch is that "diffing two trees" is, without constraints, prohibitively expensive — a full tree edit-distance algorithm runs in O(n³). So both React and Vue lean on the same assumption: same-level comparison. You only compare nodes at the same depth; a node never gets matched against one from a completely different level. That assumption brings the complexity down to O(n), at the cost of giving up the theoretically optimal solution for something fast enough in practice.
Vue's patch logic operates entirely within that constraint.
Vue 3's core diff: how the patch function compares nodes
On every update, Vue calls patch(n1, n2) — n1 is the old vnode, n2 is the new one. The first question it always asks: are these even the same kind of thing?
function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key
}If type or key differs — say a <div> turned into a <span>, or the same position in a v-for list now holds data with a different key — Vue decides there's no point comparing further. It unmounts the old node and mounts the new one, without wasting time diffing their children. Only when isSameVNodeType returns true does it move on to patchElement, where props and children actually get compared.
Diffing children: pinch from both ends, then bring out the LIS for the middle
The trickiest and most common case for diffing children is a keyed list (v-for). Vue 3's patchKeyedChildren handles it by pinching from both ends toward the middle:
- Scan from the front: compare the first nodes of the old and new arrays; as long as the
keys match, keep moving forward until you hit a mismatch. - Scan from the back: same idea, working from the end of each array toward the front.
- After both scans, if the old array is exhausted but new nodes remain, those are insertions — mount them directly. If it's the other way around, the leftover old nodes get unmounted.
- The real complexity lives in the middle chunk that neither scan touched. Here Vue builds a map from new-node
keytoindex, then looks up each old node'skeyin that map to find where it belongs in the new array.
Once it has that mapping, Vue doesn't naively move nodes in their old order. Instead, it computes the longest increasing subsequence (LIS) over the new position indices. Nodes that land on this subsequence keep their relative order and don't need to move at all — only the nodes outside it get physically relocated in the DOM. The whole point is minimizing DOM moves, since DOM operations are always the most expensive part.
The ceiling of traditional full diffing: static content still gets walked every time
The patch logic above is already a mature algorithm, but it has one inherent limitation: it's "blind" at runtime. Even if a node will literally never change, Vue still has to rebuild its vnode object from scratch on every diff pass and compare its tag, props, and children — no shortcuts.
Picture a common template:
<template>
<div class="card">
<h2>User Info</h2>
<p>This section is static and has no dynamic bindings</p>
<p>{{ user.name }}</p>
</div>
</template>Only the last <p>{{ user.name }}</p> actually changes; the other three nodes never change at all. But under a traditional full diff, every re-render still rebuilds the vnode objects for all four nodes and compares each one — even though the result is always "nothing changed." The bigger the template and the more static content it has, the more of this wasted work piles up.
Vue 3's insight was: instead of guessing what changed at runtime, why not analyze the template's AST at compile time and figure out exactly which nodes are dynamic, where, and in what way — then bake that information straight into the compiled output? At runtime, diffing no longer needs to discover what changed; it just needs to read the label.
PatchFlags: the tags the compiler sticks on dynamic nodes
That label is the PatchFlag. When the compiler analyzes a template and finds a node with a dynamic binding — an interpolation, v-bind, :class, :style, whatever — it attaches an extra numeric argument to the corresponding createElementVNode call. That number is a bitmask marking exactly what kind of thing can change on this node.
Here's a subset of Vue's internal PatchFlag definitions:
// excerpted from the PatchFlags definitions in @vue/shared
const PatchFlags = {
TEXT: 1, // 1 << 0, dynamic text content
CLASS: 2, // 1 << 1, dynamic class
STYLE: 4, // 1 << 2, dynamic style
PROPS: 8, // 1 << 3, dynamic attributes other than class/style
FULL_PROPS: 16, // 1 << 4, has dynamic keys — needs a full props comparison
HOISTED: -1, // static node, never needs patching
BAIL: -2, // unstable structure, fall back to a full diff
}These flags can be combined with bitwise | (OR) to represent "matches multiple dynamic categories at once." Compile the template above and you get output roughly like this (simplified for readability):
import { openBlock, createElementBlock, createElementVNode, toDisplayString } from 'vue'
const _hoisted_1 = /*#__PURE__*/ createElementVNode('h2', null, 'User Info', -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/ createElementVNode(
'p',
null,
'This section is static and has no dynamic bindings',
-1 /* HOISTED */,
)
export function render(_ctx) {
return (
openBlock(),
createElementBlock('div', { class: 'card' }, [
_hoisted_1,
_hoisted_2,
createElementVNode('p', null, toDisplayString(_ctx.user.name), 1 /* TEXT */),
])
)
}See the 1 /* TEXT */ trailing the last <p>? That's the PatchFlag saying: "only this node's text content can change — don't bother with anything else." When diffing hits this flag, it skips the tag, skips the props, doesn't even check the children's structure — it just does one thing: swap in the new text. What used to be "compare everything" becomes "follow the label."
If a node has both a dynamic class and dynamic text, its PatchFlag comes out to 3 (TEXT | CLASS, or 1 | 2), and diffing compares exactly those two things and nothing else.
The block tree: skipping not just how to compare, but whether to visit at all
PatchFlags solve "what should I compare on this one node," but there's a more fundamental question left: do those two fully static nodes — the <h2> and the first <p> — even need to be visited during the diff?
If the answer were "yes," then even with every node perfectly tagged, recursively walking the whole tree would still cost something. Vue 3's answer is the block tree: look closely at openBlock() and createElementBlock() in the compiled output — together, they flatten every dynamic node under a given block into a single array called dynamicChildren, no matter how deeply that dynamic node is nested inside static structure.
During diffing, Vue no longer recursively walks the whole subtree. It walks dynamicChildren directly and compares each node's own PatchFlag. Nodes that are fully static and never made it into dynamicChildren never get touched at all — not even once.
In other words, diff complexity is no longer proportional to "total number of nodes in the template" — it's proportional to "number of dynamic bindings." A template can be packed with static content, and as long as that content doesn't change, it costs nothing on every update.
Static hoisting: not even bothering to rebuild the vnode object
Look at that compiled output again: _hoisted_1 and _hoisted_2 are defined as module-level constants outside the render function — not recreated with createElementVNode on every call.
That's static hoisting. When the compiler spots a vnode that never changes, it "hoists" the code that creates it out of render — which reruns on every update — and moves it to the module scope, where it only runs once when the module loads. Every subsequent render just reuses a reference to that same vnode object.
This buys two things. First, it saves the cost of repeatedly allocating and building the same vnode object. Second, because the old and new vnodes are literally the same object reference, the diff phase can determine "this node hasn't changed" just by reference equality (n1 === n2) — combined with the -1 /* HOISTED */ flag, it skips patching entirely, no comparison needed.
Putting the three together: diffing locks onto only what actually moves
Line these three mechanisms up side by side, and they turn out to be the same idea applied at three different layers:
- Static hoisting makes sure nodes that never change don't even get rebuilt as objects.
- PatchFlags make sure nodes that do change only get compared on the specific aspect that can actually change — text, class, style, whatever.
- The block tree's
dynamicChildrenmakes sure the diff walk skips every static nested structure in between and jumps straight to the dynamic nodes.
A traditional full diff scales roughly with the total number of nodes in the template. With this whole stack of compile-time optimizations, it scales with the number of dynamic bindings instead. That's exactly why the Vue team can claim that bigger templates with a higher share of static content see bigger wins — the static parts are, for all practical purposes, free.
One exception worth knowing: when does a node BAIL?
This entire optimization rests on the compiler being able to statically analyze the template's structure. When a template's structure isn't stable — say a v-if / v-else pair that can render a completely different element at the same position, or a dynamic component (<component :is="...">) — the compiler can't guarantee the node type at that position stays consistent across renders. In that case it marks the node BAIL (-2), giving up on this section's optimization and falling back to a traditional, fully recursive diff.
That also points to a practical takeaway: the more stable a template's structure — the less it swings wildly based on conditions — the more the compiler can optimize. Conversely, if a block is destined to be swapped out wholesale on every render anyway, running the PatchFlag analysis on it is pure overhead, and falling back to a full diff (BAIL) is the right call.
Wrapping up
Vue 2's diffing ran a "blind" full comparison at runtime — every node got rebuilt and re-compared regardless of whether it could actually change. Vue 3 moved that work earlier, into compile time: the compiler analyzes the template's AST, tags every dynamic node with a PatchFlag describing exactly what to compare, flattens all dynamic nodes into dynamicChildren via the block tree, and uses static hoisting so unchanging nodes get built exactly once and skipped afterward via reference equality.
Together, these three mechanisms mean runtime diffing no longer has to figure out what changed — it just reads the answer the compiler already worked out. Understanding this isn't just about knowing why Vue 3 is faster; it also helps you write templates with more stable structure that the compiler can actually optimize, so you get the full benefit of these lower-level wins.