| |

Vue.js 83 🟢 Compiler Optimizations: Static Hoisting, Patch Flags, and Tree Flattening

⏱️ Estimated read: 13 min | |

Vue 3’s performance advantage over Vue 2 comes primarily from the compiler, not from a faster runtime diff algorithm. The compiler analyzes the template at build time and emits metadata that tells the renderer exactly which parts of the tree can change and which cannot. Static Hoisting removes immutable nodes from the render function entirely, so they are created once and reused across every render. Patch Flags annotate each dynamic VNode with a bitmask indicating which specific properties are changeable, allowing the renderer to skip property comparison for everything else . Tree Flattening collects all dynamic descendants into a flat array so the renderer iterates only the nodes that might update, ignoring the static structure that surrounds them .

This chapter covers the three optimizations as a coordinated system. It examines the compile-time analysis that marks nodes as static or dynamic, the bitmask encoding that drives targeted updates, the block tree that flattens the dynamic tree, and the pre-stringification optimization that collapses large static subtrees into single static VNodes.

Key point: The three optimizations are not independent. Static Hoisting removes nodes from the render function. Tree Flattening removes static nodes from the dynamic array. Patch Flags tell the renderer what to do with the nodes that remain. Each layer reduces the work the next layer must perform.


Why compiler optimizations matter

The pure runtime diff problem. A traditional virtual DOM implementation must assume that any node can change. It creates new VNodes on every render and compares them to the old ones, property by property. For a component with a hundred static attributes and one dynamic class, this means ninety-nine unnecessary comparisons per render . The compiler eliminates this by proving, at build time, which parts of the template are constant.

The render function recreation problem. Without optimization, a static <h1>Hello</h1> inside a render function creates a new VNode object every time the function runs. The VNode is discarded after diffing, and a new one is created on the next render. Static Hoisting moves the VNode creation outside the render function, so the same object is reused. The renderer sees the same reference and skips the diff entirely .

The nested traversal problem. A template with a static header, a static sidebar, a dynamic paragraph, and a static footer requires the diff algorithm to walk the entire tree to find the paragraph. Tree Flattening collects the paragraph into a dynamicChildren array during rendering. On update, the renderer iterates the array—which contains one node—instead of walking the tree . The static structure is never visited.

The structure instability problem. A flat dynamic array only works if the new and old arrays have a one-to-one correspondence. A v-if that changes branches breaks this correspondence. The compiler solves this by treating v-if and v-for as block boundaries: each branch becomes a new Block with its own dynamicChildren, and the parent’s dynamic array contains the block node rather than the branch’s internal nodes . The renderer recurses into the block only when the branch is active.


a. Static Hoisting: Removing Immutable Nodes

The compiler walks the AST and assigns each node a constant type. A node with no dynamic bindings, no interpolations, and no component references is marked as CAN_HOIST. The hoistStatic transform moves that node’s codegen outside the render function and assigns it to a _hoisted_N constant . The render function references the constant instead of creating a new VNode.

// Template: <div><h1>Static Title</h1><p>{{ msg }}</p></div>
// Before hoisting: every render creates all three VNodes
function render(_ctx) {
  return createVNode('div', null, [
    createVNode('h1', null, 'Static Title'),
    createVNode('p', null, _ctx.msg, 1 /* TEXT */)
  ])
}

// After hoisting: the h1 is created once, outside the render function
const _hoisted_1 = createVNode('h1', null, 'Static Title')

function render(_ctx) {
  return createVNode('div', null, [
    _hoisted_1, // Reused, never recreated
    createVNode('p', null, _ctx.msg, 1 /* TEXT */)
  ])
}

The hoisted VNode is marked with PatchFlags.HOISTED (-1), a special negative flag that tells the renderer to skip the entire subtree during patching and hydration . A large static tree can be collapsed further by pre-stringification: consecutive hoisted nodes are converted into a single createStaticVNode call with an HTML string, and the runtime inserts the nodes via innerHTML in a hidden container . This is performed only in Node.js builds because it requires the compiler’s stringification pass.


b. Patch Flags: Bitmask Encoding of Dynamic Properties

Every dynamic VNode receives a patchFlag from the compiler. The flag is an integer bitmask where each bit represents a category of dynamic content. The renderer reads the flag with a bitwise AND and updates only the properties that are marked .

// PatchFlag values (from packages/shared/src/patchFlags.ts)
const PatchFlags = {
  TEXT: 1,          // dynamic textContent
  CLASS: 1 << 1,    // dynamic class binding
  STYLE: 1 << 2,    // dynamic style binding
  PROPS: 1 << 3,    // dynamic non-class/style props
  FULL_PROPS: 1 << 4, // dynamic prop keys (full diff needed)
  // ...
}

// Template: <div :class="active">{{ msg }}</div>
// Compiler emits patchFlag = CLASS | TEXT = 3
const vnode = createVNode('div', { class: _ctx.active }, _ctx.msg, 3)

// Renderer in patchElement:
if (patchFlag & PatchFlags.CLASS) {
  // update class only
}
if (patchFlag & PatchFlags.TEXT) {
  // update textContent only
}
// STYLE and PROPS bits are not set, so those comparisons are skipped

The dynamicProps array accompanies the PROPS flag. When the compiler cannot determine which specific props are dynamic—for example, when a v-bind="obj" spreads an object—it marks the VNode with FULL_PROPS and the renderer performs a complete props diff to handle removed keys . The CACHED flag (-1) marks cached static VNodes, and BAIL (-2) tells the renderer to exit optimized mode, which happens when manually written render functions are mixed with compiler output .


c. Tree Flattening: The Block Tree and Dynamic Arrays

The Block Tree is the structural optimization that makes Patch Flags useful across nested trees. When the compiler processes a template, it identifies the root and every structural directive (v-if, v-for) as Block boundaries. Each Block collects its dynamic descendants into a dynamicChildren array during rendering . The openBlock() and createBlock() functions manage this collection: openBlock initializes a new array, createVNode pushes dynamic VNodes into the current array, and createBlock assigns the array to the block VNode’s dynamicChildren property .

// Template: <div><header>Static</header><p>{{ msg }}</p></div>
// Generated render function:
import { openBlock, createBlock, createVNode, toDisplayString } from 'vue'

const _hoisted_1 = createVNode('header', null, 'Static')

function render(_ctx) {
  return (
    openBlock(),
    createBlock('div', null, [
      _hoisted_1, // Not in dynamicChildren
      createVNode('p', null, toDisplayString(_ctx.msg), 1 /* TEXT */)
    ])
  )
}
// The p VNode is in dynamicChildren; the header is not.
// On update, patchBlockChildren iterates only [p].

The dynamicChildren array is flat, but the VNodes it contains retain their original nesting. The renderer’s patchBlockChildren function iterates the array and patches each VNode against its counterpart in the old array by index. The correspondence is guaranteed because the block’s structure is stable—no v-if or v-for inside the block changes the number or order of children . When a v-if toggles, the block itself is replaced, and the renderer recurses into the new block’s dynamicChildren rather than attempting to match mismatched arrays.


Complete Example Session

// ============================================
// PART 1: TEMPLATE WITH STATIC AND DYNAMIC CONTENT
// ============================================
// <div>
//   <header><h1>My App</h1></header>
//   <main><p>{{ message }}</p></main>
//   <footer>Copyright</footer>
// </div>


// ============================================
// PART 2: COMPILER ANALYZES CONSTANT TYPES
// ============================================
// header: CAN_HOIST (no dynamic bindings)
// h1: CAN_HOIST (inside hoisted parent)
// p: dynamic (TEXT interpolation)
// footer: CAN_HOIST


// ============================================
// PART 3: STATIC HOISTING OUTPUT
// ============================================
// The header and footer are hoisted outside render()
const _hoisted_1 = createVNode('header', null, [
  createVNode('h1', null, 'My App')
])
const _hoisted_2 = createVNode('footer', null, 'Copyright')


// ============================================
// PART 4: BLOCK TREE COLLECTION
// ============================================
// The root div is a Block.
// Its dynamicChildren array collects only the dynamic p.
function render(_ctx) {
  return (
    openBlock(),
    createBlock('div', null, [
      _hoisted_1, // Static, not in dynamicChildren
      createVNode('main', null, [
        createVNode('p', null, _ctx.message, 1 /* TEXT */)
      ]),
      _hoisted_2 // Static, not in dynamicChildren
    ])
  )
}
// dynamicChildren = [p VNode]


// ============================================
// PART 5: PATCH FLAG ENCODING
// ============================================
// The p VNode has patchFlag = 1 (TEXT)
// The renderer checks: patchFlag & TEXT → true
// It updates textContent only.
// It does not diff the main element's props or children structure.


// ============================================
// PART 6: UPDATE ITERATION
// ============================================
// On message change:
// 1. Render function runs again, creates new p VNode
// 2. patchBlockChildren iterates dynamicChildren
// 3. Only [p] is visited
// 4. header, h1, main, footer are never touched


// ============================================
// PART 7: v-if CREATES A NEW BLOCK
// ============================================
// <div v-if="show">{{ a }}</div><div v-else>{{ b }}</div>
// Each branch is a Block with its own dynamicChildren.
// The parent's dynamicChildren contains the block node,
// not the inner text nodes.
// When show changes, the block is replaced entirely.


// ============================================
// PART 8: PRE-STRINGIFICATION
// ============================================
// A large static tree (e.g., 20 spans) is collapsed
// into a single createStaticVNode call:
const _hoisted_3 = createStaticVNode(
  '<div><span></span>...</div>', 1
)
// The runtime inserts via innerHTML.
// This is a compile-time optimization only.


// ============================================
// PART 9: PATCHFLAG COMBINATIONS
// ============================================
// <div :class="cls" :style="style">{{ text }}</div>
// patchFlag = CLASS | STYLE | TEXT = 2 | 4 | 1 = 7
// Renderer checks each bit independently.


// ============================================
// PART 10: BAIL FLAG FOR MANUAL RENDER
// ============================================
// A manually written render function that uses h()
// without compiler flags produces VNodes with patchFlag = 0.
// The renderer falls back to full diff for those nodes.

The ten parts trace the compiler’s analysis through the generated code to the runtime update. The critical observation is in Part 6: the update visits only the p VNode, not the static header, main, or footer.


Quick Reference

Compiler Optimizations

OptimizationMechanismEffect
Static HoistingMoves static VNodes outside renderCreated once, reused
Pre-stringificationCollapses static trees into HTML stringFewer VNodes, faster mount
Patch FlagsBitmask of dynamic propertiesTargeted updates
Tree FlatteningdynamicChildren arraySkips static tree walk

Common Patch Flags

FlagValueMeaning
TEXT1Dynamic textContent
CLASS2Dynamic class binding
STYLE4Dynamic style
PROPS8Dynamic non-class/style props
FULL_PROPS16Dynamic prop keys
HOISTED-1Static hoisted node
BAIL-2Exit optimized mode

Block Boundaries

ConstructCreates Block?
Component rootYes
v-if / v-elseYes (each branch)
v-forYes (each iteration)
Static elementNo

Best Practices

✅ Do This:

<!-- Use templates, not manual render functions -->
<!-- The compiler optimizes templates automatically -->                    <!-- ✅ -->
<!-- Keep dynamic bindings minimal -->
<!-- Every :class or :style adds a PatchFlag bit -->                        <!-- ✅ -->
<!-- Use v-once for content that never changes after mount -->
<div v-once>{{ staticContent }}</div>                                     <!-- ✅ -->

❌ Don’t Do This:

<!-- Use manual h() render functions when a template would work -->
<!-- Manual VNodes have patchFlag = 0 and require full diff -->            <!-- ❌ -->
<!-- Bind dynamic values to attributes that never change -->
<div :title="'Constant'">  <!-- Compiler marks as dynamic unnecessarily --><!-- ❌ -->
<!-- Assume static hoisting applies to component children -->
<!-- Component slots are dynamic and cannot be hoisted -->                 <!-- ❌ -->

Common Pitfalls

PitfallWhy It HappensFix
Static content re-rendersManual render function bypasses hoistingUse template syntax
PatchFlag not appliedVNode created manually with h()Let compiler generate VNodes
Large static tree still slowPre-stringification threshold not metAccept or restructure
v-if branch switching slowBlock replacement, not diffExpected behavior for structure change

Real-World Examples

1. Hoisted Static Header

const _hoisted_1 = createVNode('header', null, 'Title')

2. Dynamic Text with PatchFlag

createVNode('p', null, msg, 1 /* TEXT */)

3. Combined PatchFlags

createVNode('div', { class: cls }, text, 3 /* CLASS | TEXT */)

4. Block with DynamicChildren

openBlock(); createBlock('div', null, children)

5. v-if Block Boundary

show ? (openBlock(), createBlock('div', ...)) : (openBlock(), createBlock('span', ...))

6. Pre-stringified Static Tree

const _hoisted_2 = createStaticVNode('<div>...</div>', 1)

7. Hoisted PatchFlag

_hoisted_1.patchFlag = -1 // HOISTED

8. Bail Flag for Manual Render

// patchFlag = -2 when optimized mode should exit

9. Dynamic Props Array

createVNode('div', props, null, 8 /* PROPS */, ['id', 'title'])

10. Static Props Hoisting

// style="color: red" hoisted to const style = { color: 'red' }

Visual

Static Hoisting

┌──────────────────────────────────────────────────────────────┐
│  WITHOUT HOISTING              WITH HOISTING                 │
│                                                              │
│  render() {                    const _hoisted_1 = ...       │
│    createVNode('div', ...)     render() {                    │
│      createVNode('h1', ...)      createVNode('div', ...)    │
│      createVNode('p', ...)        _hoisted_1                 │
│    }                              createVNode('p', ...)     │
│  }                              }                            │
│                                                              │
│  h1 created every render       h1 created once              │
│  New object each time          Same reference reused        │
│                                                              │
│  The renderer sees the same reference and skips diff.        │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Patch Flags

┌──────────────────────────────────────────────────────────────┐
│  <div :class="active">{{ msg }}</div>                        │
│                                                              │
│  patchFlag = CLASS | TEXT = 2 | 1 = 3                       │
│                                                              │
│  ┌─────────────────────────────────────────┐                │
│  │ patchElement(vnode, patchFlag)          │                │
│  │                                         │                │
│  │ if (patchFlag & CLASS) { updateClass() }│                │
│  │ if (patchFlag & TEXT) { updateText() }  │                │
│  │ if (patchFlag & STYLE) { /* skipped */ }│                │
│  │ if (patchFlag & PROPS) { /* skipped */ }│                │
│  └─────────────────────────────────────────┘                │
│                                                              │
│  Only the marked properties are compared.                    │
│  Static props are never visited.                             │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Tree Flattening

┌──────────────────────────────────────────────────────────────┐
│  TEMPLATE TREE                    dynamicChildren ARRAY      │
│                                                              │
│  div (Block)                      [p]                        │
│  ├── header (static)                                         │
│  ├── main                                                    │
│  │   └── p (dynamic)  ──────────► p                           │
│  └── footer (static)                                         │
│                                                              │
│  Update iteration:                                           │
│  1. patchBlockChildren(dynamicChildren)                      │
│  2. Iterate [p]                                              │
│  3. Patch p only                                             │
│  4. header, main, footer never visited                       │
│                                                              │
│  A 1000-node tree with 10 dynamic nodes diffs 10, not 1000.  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Block Boundaries for v-if

┌──────────────────────────────────────────────────────────────┐
│  <div>                                                       │
│    <p>Static</p>                                             │
│    <div v-if="show">A</div>                                  │
│    <div v-else>B</div>                                       │
│  </div>                                                      │
│                                                              │
│  Root Block: dynamicChildren = [ifBlock]                    │
│                                                              │
│  ┌─────────────────┐                                        │
│  │ ifBlock         │                                        │
│  │ (show ? A : B)  │                                        │
│  │                 │                                        │
│  │ dynamicChildren │  Contains the active branch's nodes    │
│  │ = [A] or [B]    │                                        │
│  └─────────────────┘                                        │
│                                                              │
│  When show changes, the ifBlock is replaced.                 │
│  The renderer does not diff A against B.                     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Static HoistingMoves constant VNodes outside render function
Hoisted flag-1, skip hydration and update
Pre-stringificationStatic tree → single createStaticVNode with HTML
Patch FlagsBitmask of dynamic property categories
TEXT1
CLASS2
STYLE4
PROPS8
Tree FlatteningdynamicChildren array of dynamic descendants
Block boundariesRoot, v-if, v-for
Bail flag-2, exit optimized mode for manual render

Key takeaways:

  • Static Hoisting removes immutable nodes from the render function. A static <h1> is created once as a _hoisted_N constant and reused on every render. The renderer sees the same reference and skips diffing it entirely . Large static trees can be collapsed further into a single HTML string via pre-stringification .
  • Patch Flags encode what can change. Each dynamic VNode carries an integer bitmask. The renderer checks each bit with a bitwise AND and updates only the marked properties. Static props, styles, and classes are never compared . This is why a component with a hundred static props and one dynamic class diffs only the class.
  • Tree Flattening eliminates the tree walk. The compiler collects all dynamic descendants into a flat dynamicChildren array. On update, the renderer iterates this array instead of recursing through the static structure . A tree with a thousand static nodes and ten dynamic nodes diffs ten nodes, not a thousand.
  • Structural directives create Block boundaries. A v-if or v-for breaks the one-to-one correspondence that the flat array requires. The compiler treats each branch or iteration as a new Block, and the parent’s dynamic array contains the block reference rather than the branch’s internal nodes . When the branch changes, the block is replaced.
  • The optimizations are layered. Hoisting removes nodes from the render function. Tree Flattening removes static nodes from the dynamic array. Patch Flags tell the renderer what to do with the remaining nodes. Each optimization reduces the work the next one must perform.

Remember: Vue 3’s rendering performance is not the result of a faster diff algorithm. It is the result of the compiler proving, at build time, which parts of the template can never change. Static Hoisting takes those parts out of the render function. Tree Flattening takes them out of the dynamic array. Patch Flags tell the renderer what remains. The renderer never sees the static tree during updates because the compiler has already removed it. This is why template syntax is recommended over manual render functions: the compiler can only optimize what it can analyze.

Was this helpful?


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!