Block tree, patch flags and hoisting in detail
The Vue 3 template compiler analyzes every template at build time and produces render functions that already know which parts can possibly change at runtime. Understanding these compiler optimizations lets you write templates that benefit fully from them instead of accidentally triggering slow fallback paths.
Table of Contents
- 1. Why the Vue 3 compiler works differently from Vue 2
- 2. Static hoisting: creating immutable vnodes once
- 3. Patch flags: targeted diffing instead of full comparison
- 4. Block tree: tracking dynamic children directly
- 5. Cache handlers: event handlers without recreation
- 6. v-once and v-memo: manual optimization levers
- 7. SSR-specific compiler optimizations
- 8. Steering compiler behavior via Vite and SFC options
- 9. Compiler optimizations compared
- 10. Summary
- 11. FAQ
1. Why the Vue 3 compiler works differently from Vue 2
Vue 2 translated templates into render functions that produced a fully new virtual DOM tree at runtime and then compared it in its entirety against the previous tree. Vue 3 takes a different approach: the template compiler analyzes the template at build time and marks precisely which nodes can ever change. These Vue 3 compiler optimizations move work from expensive runtime diffing into a one time analysis performed while building the application.
The effect is not an academic footnote, it is measurable: in benchmarks with many dynamic bindings per component, pure patch time drops noticeably compared to Vue 2, because the virtual DOM comparison no longer has to recurse through static subtrees. Understanding the Vue 3 compiler optimizations changes how you write templates: static structure stays static, dynamic bindings are kept as narrow as possible, and expensive constructs such as a v-if with a complex condition deep inside nested markup are avoided when they prevent the compiler from optimizing.
Important for understanding this: all of these compiler optimizations happen automatically as soon as single file components are built through the official Vue compiler, whether via Vite, vue-loader or the standalone compiler. There is no switch to disable it in the normal case, but there are templates that prevent the optimization, for instance a dynamic v-bind="object" without known keys, or hand written render functions instead of compiled ones.
2. Static hoisting: creating immutable vnodes once
Static hoisting is the first and simplest of the Vue 3 compiler optimizations. If a template contains elements without any dynamic bindings at all, the compiler creates the corresponding vnode exactly once outside the render function and references it again on every subsequent render. Instead of creating a new object for a static <div class="header">Title</div> on every re-render, the same vnode is reused, which reduces allocations and garbage collection pressure.
This compiler optimization also applies to nested static structures: an entire static subtree is hoisted as a whole, not just individual elements. That means a large but immutable navigation bar costs practically nothing during rendering, because its vnode tree is created once and only referenced afterward. In practice this means purely presentational, never dynamic markup blocks should stay genuinely free of any bindings, so the compiler can recognize them as a whole.
// Compiled output (simplified) — Vue 3 hoists fully static vnodes
// Source template:
// <div class="card">
// <h3>Static Title</h3>
// <p>{{ dynamicText }}</p>
// </div>
import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString } from "vue"
// Hoisted once, outside render() — reused across every re-render
const _hoisted_1 = /*#__PURE__*/ _createElementVNode("h3", null, "Static Title", -1 /* HOISTED */)
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", { class: "card" }, [
_hoisted_1,
_createElementVNode("p", null, _toDisplayString(_ctx.dynamicText), 1 /* TEXT */)
]))
}
A common misunderstanding: static hoisting no longer works once an otherwise static element sits inside a v-for loop, because it then has its own potentially different key context per iteration. The compiler correctly detects this and skips hoisting in that case, which reviewers sometimes mistake for a bug when it is actually correct behavior of the Vue 3 compiler optimizations.
3. Patch flags: targeted diffing instead of full comparison
Patch flags are the core of the Vue 3 compiler optimizations for dynamic elements. For every vnode with dynamic content, the compiler marks precisely which kind of change is possible: only the text content, only a specific class, only a style attribute, or a combination of those. At runtime the patch algorithm no longer has to compare every attribute and child of an element, it reads the patch flag and updates only what the analysis says can possibly change.
The most important patch flags are TEXT for dynamic text content, CLASS for dynamic class bindings, STYLE for dynamic inline styles, PROPS for a known list of dynamic props and FULL_PROPS as a fallback when the prop names are not known at compile time, for example with v-bind="object". That last case shows exactly why the Vue 3 compiler optimizations do less for certain patterns: FULL_PROPS forces Vue to compare all props again, because the compiler does not know the concrete keys.
// Compiled output — patch flags tell the runtime exactly what can change
// Source template:
// <span :class="active ? 'on' : 'off'">{{ label }}</span>
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, normalizeClass as _normalizeClass } from "vue"
export function render(_ctx) {
return _createElementVNode(
"span",
{ class: _normalizeClass(_ctx.active ? 'on' : 'off') },
_toDisplayString(_ctx.label),
3 /* TEXT, CLASS — only these two aspects are diffed at runtime */
)
}
// Anti-pattern that disables patch flags:
// <span v-bind="dynamicAttrs">{{ label }}</span>
// compiles with flag 16 /* FULL_PROPS */ — the runtime must diff
// every attribute again because the compiler cannot know the keys.
In practice it is worth checking the compiled output via the Vue SFC Playground once a component shows up in a performance profile. Seeing that an expected optimization compiled as FULL_PROPS instead of a specific flag usually reveals a v-bind="object" pattern that can be replaced with explicit props without making the component more cumbersome to use.
4. Block tree: tracking dynamic children directly
Patch flags alone would only help if the entire tree were still traversed recursively to find the flagged nodes. That is exactly what the second central compiler optimization prevents: the block tree. A block is a vnode that collects a flat list of all its dynamic descendants, regardless of how deeply they are nested inside static markup. On re-render, Vue only has to walk this flat list instead of traversing the whole tree from the root.
This compiler optimization explains why deeply nested but predominantly static markup is no longer a performance problem in Vue 3, while it certainly was one in older virtual DOM implementations. Conditional structures such as v-if/v-else and v-for each open their own block, because their child structure can fundamentally change at runtime. Inside those blocks the dynamic children list is rebuilt as well, so nested conditions are handled correctly without unnecessarily invalidating outer blocks.
A practical tip: if a component triggers an unusually high number of DOM updates, check whether a v-for loop accidentally creates many independent blocks whose dynamic children lists have to be recomputed on every iteration. A stable :key that genuinely represents an element's identity rather than just its index helps the block tree reuse elements correctly instead of recreating them.
5. Cache handlers: event handlers without recreation
One frequently overlooked Vue 3 compiler optimization is cache handlers. Without this optimization, every render function call with an inline event handler like @click="count++" would create a new closure on every invocation. That new function reference would be passed as a changed prop to child components even if the actual behavior did not change, potentially triggering unnecessary re-renders in children that react to prop reference equality.
The compiler recognizes inline defined handlers and caches the created function via _cache[n] || (_cache[n] = handler) in the component's render context. On every subsequent render, the same cached function reference is returned, as long as the variables referenced by the handler have not changed. In practice this means: inline handlers in Vue 3 are, contrary to intuition from other frameworks, usually more performant than a manually memoized method, because the compiler already handles it automatically.
// Compiled output — cached event handler, same reference across renders
// Source template: <button @click="count++">{{ count }}</button>
export function render(_ctx, _cache) {
return _createElementVNode(
"button",
{
// _cache[0] holds the closure after the first render call
onClick: _cache[0] || (_cache[0] = ($event) => (_ctx.count++))
},
_toDisplayString(_ctx.count),
1 /* TEXT */
)
}
Important for developers is the boundary of this compiler optimization: cache handlers only applies to handlers defined inline in the template, not to methods passed as a named reference, such as @click="handleClick". In that case the function reference stays stable across the component's whole lifecycle anyway, as long as handleClick is not recreated on every setup call, which is automatically the case for methods in the options API or stably defined functions inside setup().
6. v-once and v-memo: manual optimization levers
Where automatic Vue 3 compiler optimizations reach their limits, Vue offers two explicit directives. v-once renders a subtree exactly once and then skips it entirely on every subsequent update, regardless of whether referenced data has changed. This suits content that is guaranteed to remain constant after the first render, for example a formatting derived from props that never actually changes in practice.
v-memo is the more flexible lever: it takes an array of dependencies and skips re-rendering the marked subtree as long as none of the values in that array have changed since the last render. This is especially valuable in long v-for lists where every item is actually unchanged, but would still be re-evaluated because an update to a parent reactive state invalidates the list's block tree as a whole.
// v-memo skips re-render of list items whose dependencies are unchanged
// Useful for long lists where a sibling state update would otherwise
// force a full re-evaluation of every row's block tree.
</script>
<template>
<div
v-for="item in largeList"
:key="item.id"
v-memo="[item.id === selectedId, item.updatedAt]"
>
<ExpensiveRow :item="item" :selected="item.id === selectedId" />
</div>
</template>
</code></pre>
<p>The most important practical note about these two compiler optimizations: both are trade-offs, not free wins. <code>v-once</code> and <code>v-memo</code> prevent correct updates when the dependency list is incomplete. A forgotten reactive value in the <code>v-memo</code> array leads to a subtree that stays stale even though the underlying data changed, a bug that is easily missed in tests because it only surfaces under specific update orders.</p>
<h2 id="ssr">7. SSR-specific compiler optimizations</h2>
<p>For server side rendering, as used by default in Nuxt, Vue compiles templates into an entirely different mode: instead of creating vnodes, the SSR compiler generates direct string concatenations. These SSR-specific Vue 3 compiler optimizations avoid the cost of building a virtual DOM tree at all when the end result is just an HTML string streamed to the client anyway.</p>
<p>Static parts are likewise prepared as finished string fragments at build time, so only dynamic values need to be inserted at runtime. That is why pure server rendering without hydration is typically many times faster than client side rendering of the same component: there is no vnode diff, no patch phase, just string assembly. Anyone deliberately optimizing a component for SSR performance benefits most from keeping large static blocks genuinely static, so the SSR compiler can treat them as pre-built strings rather than interpolating them on every request.</p>
<h2 id="config">8. Steering compiler behavior via Vite and SFC options</h2>
<p>Most Vue 3 compiler optimizations are hard wired into the compiler and require no configuration. Still, there are levers that influence behavior. In the <code>@vitejs/plugin-vue</code> Vite plugin, <code>template.compilerOptions</code> lets you specify which custom elements the compiler should ignore, which prevents web components from being mistakenly treated as unknown Vue components and triggering warnings or suboptimal compiler decisions.</p>
<p>For the production build it is also worth checking <code>__DEV__</code> flags: the Vue compiler automatically strips development warnings and extra prop validations from generated code in production, an optimization that only kicks in if <code>process.env.NODE_ENV</code> is correctly set to <code>production</code>. A common mistake in custom built build pipelines: this variable is forgotten, so all Vue 3 compiler optimizations still apply, but extra dev only code remains in the bundle and unnecessarily inflates bundle size.</p>
<pre class="language-js"><code><script type="text/plain">
// vite.config.js — tuning compiler behavior for custom elements and props
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Prevent the compiler from warning about known custom elements
isCustomElement: (tag) => tag.startsWith("ion-"),
// Keep whitespace handling predictable across environments
whitespace: "condense"
}
}
})
]
})
9. Compiler optimizations compared
The following overview ranks the most important Vue 3 compiler optimizations by their scope and shows when each optimization applies and when it gets undermined by certain template patterns.
| Optimization | Affects | Undermined by | Benefit |
|---|---|---|---|
| Static hoisting | Elements without dynamic bindings | Position inside v-for |
No vnode recreation |
| Patch flags | Individual dynamic attributes | v-bind="object" without known keys |
Targeted instead of full diffing |
| Block tree | Nested dynamic children | Unstable :key values in lists |
Flat traversal instead of recursion |
| Cache handlers | Inline event handlers | Externally bound, recreated functions | Stable prop references for children |
| v-memo | Expensive list items | Incomplete dependency array | Manual skipping of re-renders |
This table also shows why plain template reviews without looking at the compiled output are not very meaningful: two syntactically similar templates can lead to completely different patch flag behavior, depending on whether bindings are statically analyzable or not. Taking the Vue 3 compiler optimizations seriously means making the compiled output part of performance reviews for critical components.
Mironsoft
Vue.js and Nuxt performance engineering for production frontends
Vue templates that actually use the compiler?
We analyze compiled render code, identify templates that undermine patch flags and block tree optimizations, and refactor deliberately for measurably faster rendering in Vue and Nuxt applications.
Compiler audit
Checking compiled output of critical components for patch flag losses
Template refactoring
Applying v-memo, v-once and stable props exactly where it counts
Build configuration
Setting up Vite and compiler options cleanly for production
10. Summary
The Vue 3 compiler optimizations move as much analysis work as possible from runtime into the build process. Static hoisting creates immutable vnodes once, patch flags mark exactly what can change per element, the block tree collects dynamic children into a flat list instead of searching for them recursively, and cache handlers prevent unnecessary function recreation for inline events. v-once and v-memo add manual levers for cases the compiler cannot detect on its own.
The practical benefit shows up most in components with many dynamic bindings or long lists: structuring templates so static and dynamic parts stay clearly separated, and avoiding v-bind="object" patterns, gets you the full Vue 3 compiler optimizations without extra code. For the remaining cases, the compiled output via the SFC Playground gives you the transparency needed to fix things deliberately.
Vue 3 Compiler Optimizations — Key Takeaways
Static hoisting
Static vnodes are created once and only referenced afterward, static markup is practically free on re-renders.
Patch flags & block tree
Targeted diffing instead of full comparison, dynamic children collected flatly instead of searched recursively.
Cache handlers
Inline event handlers keep the same function reference across re-renders, stable props for child components.
v-once & v-memo
Manual levers for cases the compiler cannot detect automatically, with the risk of stale subtrees when used incorrectly.