Vue 3.6 Vapor Mode — Goodbye Virtual DOM, Solid.js-level Performance
Posted on: 4/21/2026 7:08:12 AM
The Virtual DOM Problem
Since React introduced the Virtual DOM in 2013, most modern frontend frameworks have adopted the same model: whenever state changes, the framework builds a new Virtual DOM tree (a lightweight JavaScript object graph), diffs it against the previous tree, and patches the resulting delta onto the real DOM.
This model delivers excellent DX — developers write declarative code and let the framework do the rest. But that convenience has a price:
Vue 3 already significantly optimized this with a compiler-informed VDOM — the compiler analyzes templates at build time and tags (via patch flags) exactly which nodes are actually dynamic, letting the runtime skip the static ones. But the core model is unchanged: runtime diffing, VNode allocation, memory pressure.
The question
If the compiler already knows at build time which bindings are dynamic, why bother with a Virtual DOM at all? Why not emit code that updates the DOM directly?
That's exactly the idea behind Vapor Mode.
What Is Vapor Mode?
Vapor Mode is a new compilation strategy in Vue 3.6 that lets the compiler translate a template into direct DOM operations — with no Virtual DOM layer in between. The name "Vapor" hints at the VDOM abstraction "evaporating" at compile time.
The key detail: developers don't change how they write code. Same <template>, same Composition API, same ref() / computed() — only the compiler output is different.
Compile-time Compilation Strategy
graph LR
A["<template>
SFC Source"] --> B["Vue Compiler
Parse + Analyze"]
B --> C{"Vapor
enabled?"}
C -->|No| D["VDOM Render
Function"]
C -->|Yes| E["Direct DOM
Operations"]
D --> F["Runtime Diff
+ Patch"]
E --> G["Reactive
DOM Updates"]
F --> H["Real DOM"]
G --> H
style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style B fill:#e94560,stroke:#fff,color:#fff
style C fill:#ff9800,stroke:#fff,color:#fff
style D fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style E fill:#4CAF50,stroke:#fff,color:#fff
style F fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style G fill:#4CAF50,stroke:#fff,color:#fff
style H fill:#2c3e50,stroke:#fff,color:#fff
Compilation pipeline: Virtual DOM mode vs Vapor Mode
Output Comparison: Virtual DOM vs Vapor
Consider this simple component:
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<div class="counter">
<h1>Counter</h1>
<p>Value: {{ count }}</p>
<button @click="count++">Increment</button>
</div>
</template>
Virtual DOM mode emits a render function that returns a VNode tree:
// Simplified output — VDOM mode
function render(_ctx) {
return createVNode("div", { class: "counter" }, [
createVNode("h1", null, "Counter"),
createVNode("p", null, "Value: " + _ctx.count),
createVNode("button", { onClick: () => _ctx.count++ }, "Increment")
])
}
// Every re-render: rebuild the ENTIRE VNode tree -> diff -> patch
Vapor Mode emits code that builds real DOM once and then updates only the parts that change:
// Simplified output — Vapor mode
function setup() {
const count = ref(0)
// Build real DOM — runs ONCE
const div = document.createElement("div")
div.className = "counter"
const h1 = document.createElement("h1")
h1.textContent = "Counter" // static — never updated
const p = document.createElement("p")
const btn = document.createElement("button")
btn.textContent = "Increment"
btn.addEventListener("click", () => count.value++)
div.append(h1, p, btn)
// Reactive effect — updates ONLY the affected text node
watchEffect(() => {
p.textContent = "Value: " + count.value
})
return div
}
The core difference
Virtual DOM: rebuild the entire object tree on EVERY re-render, diff it, patch. Vapor: build the real DOM ONCE, and a fine-grained reactive effect runs only for the specific expression that changed. No allocation, no diffing.
Inside Vapor Mode
Compiler Pipeline
Vapor Mode reuses the Vue compiler's frontend (SFC parse → AST), but from code generation onward it branches into a completely different backend:
graph TD
A["SFC Source Code"] --> B["@vue/compiler-sfc
Parse SFC"]
B --> C["Template AST"]
C --> D["Static Analysis
Classify nodes"]
D --> E["Split Static vs Dynamic"]
E --> F["Static Nodes
-> Template Literal
(one-shot innerHTML)"]
E --> G["Dynamic Bindings
-> Fine-grained
Reactive Effects"]
F --> H["Compiled Output"]
G --> H
H --> I["Minimal Runtime
~3KB"]
style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style B fill:#e94560,stroke:#fff,color:#fff
style C fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style D fill:#e94560,stroke:#fff,color:#fff
style E fill:#ff9800,stroke:#fff,color:#fff
style F fill:#4CAF50,stroke:#fff,color:#fff
style G fill:#4CAF50,stroke:#fff,color:#fff
style H fill:#2c3e50,stroke:#fff,color:#fff
style I fill:#e94560,stroke:#fff,color:#fff
Vapor Mode compilation pipeline
Key steps:
- Parse: the SFC is parsed into an AST — this step is shared with VDOM mode
- Static Analysis: the compiler classifies each node as static (never changes) or dynamic (depends on reactive state)
- Template Hoisting: static subtrees are grouped into a single template literal and created with one
innerHTMLcall — much cheaper than manycreateElementcalls - Effect Generation: each dynamic binding gets wrapped in its own reactive effect that only re-runs when its dependency changes
- Minimal Runtime: Vapor doesn't need the VDOM runtime (~45KB); it only needs a small runtime (~3KB) for reactive scheduling
Reactivity System Integration
Vapor Mode uses Vue 3's existing reactivity system (ref, computed, watch, effect) — it does not introduce a new one. The difference lies in how the compiler wires reactive primitives to the DOM:
VDOM mode: a component re-render = re-run the entire render function → build a new VNode tree → diff
Vapor mode: each dynamic expression = its own effect → when count.value changes, ONLY the p.textContent effect re-runs
This produces expression-level granularity instead of component-level. In a component with 50 dynamic bindings, if only 1 changes, only 1 effect runs — not a 50-VNode rebuild and diff.
Benchmark: Vue 3.6 vs React 19 vs Svelte 5
Based on community benchmarks and the Vue team's Q1 2026 numbers:
| Metric | React 19 | Vue 3.6 (VDOM) | Vue 3.6 (Vapor) | Svelte 5 |
|---|---|---|---|---|
| Ops/sec (DOM benchmark) | 28.4 | ~27 | 31.2 | 39.5 |
| Bundle size (baseline) | 72 KB | 58 KB | ~10 KB | 28 KB |
| Mount 100K components | ~350 ms | ~200 ms | ~100 ms | ~100 ms |
| First Contentful Paint | 1.2s | 0.9s | 0.7s | 0.8s |
| Lighthouse Score | 92 | 93 | 94 | 96 |
| Memory (100K nodes) | ~180 MB | ~120 MB | ~65 MB | ~70 MB |
| Rendering style | VDOM + auto memo | VDOM + patch flags | Direct DOM | Compiled reactivity |
Takeaway
Vapor Mode moves Vue from "on par with React" up to "on par with Solid.js / Svelte" in raw performance — while keeping the API unchanged. The bundle reduction is especially striking: from 58KB down to ~10KB, smaller than Svelte.
Using Vapor Mode in Real Projects
Vite Configuration
Vapor Mode requires Vue 3.6+ and an updated Vite Vue plugin:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({
features: {
vaporMode: true // Enable the Vapor compiler
}
})
]
})
Writing a Vapor Component
Opt in by adding the vapor attribute to <script setup>:
<!-- ProductCard.vue — Vapor component -->
<script setup vapor>
import { ref, computed } from 'vue'
interface Props {
name: string
price: number
discount?: number
}
const props = defineProps<Props>()
const quantity = ref(1)
const finalPrice = computed(() => {
const base = props.price * quantity.value
return props.discount
? base * (1 - props.discount / 100)
: base
})
const addToCart = () => {
// emit event or call store
}
</script>
<template>
<div class="product-card">
<h3>{{ name }}</h3>
<p class="price">${{ finalPrice.toFixed(2) }}</p>
<div class="quantity">
<button @click="quantity = Math.max(1, quantity - 1)">−</button>
<span>{{ quantity }}</span>
<button @click="quantity++">+</button>
</div>
<button class="add-btn" @click="addToCart">Add to cart</button>
</div>
</template>
No code rewrite required
Composition API, ref(), computed(), defineProps, defineEmits — all of it works the same. Just add the vapor attribute to <script setup>.
Mixing Vapor and Virtual DOM
Vapor Mode works at the component level — a single app can mix VDOM and Vapor components freely:
graph TD
A["App.vue
(VDOM mode)"] --> B["Header.vue
(VDOM mode)"]
A --> C["ProductList.vue
(Vapor)"]
A --> D["Sidebar.vue
(VDOM mode)"]
C --> E["ProductCard.vue
(Vapor)"]
C --> F["FilterBar.vue
(Vapor)"]
D --> G["CartWidget.vue
(VDOM mode)"]
style A fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
style B fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style C fill:#e94560,stroke:#fff,color:#fff
style D fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style E fill:#e94560,stroke:#fff,color:#fff
style F fill:#e94560,stroke:#fff,color:#fff
style G fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
Mixing VDOM and Vapor components inside one app
That enables gradual migration: start with performance-critical components (large lists, animations, real-time data), then expand as Vapor Mode stabilizes.
Limitations and Caveats
As of April 2026, Vapor Mode is still in beta. A few important limitations:
| Limitation | Detail | Impact |
|---|---|---|
| Only <script setup> is supported | The Options API and <script> (without setup) are not yet supported |
Migrate to Composition API first |
| No Suspense support | Suspense is still experimental and not in Vapor | Async component patterns are affected |
| Ecosystem not fully ready | Some UI libraries (Vuetify, PrimeVue) aren't fully compatible yet | Best for custom components for now |
| Limited DevTools support | Vue DevTools doesn't fully support inspecting Vapor components yet | Debugging is harder during beta |
| No manual render functions | If you use h() render functions instead of templates, Vapor can't apply |
You must use the <template> block |
Production caveat
The Vue team recommends using Vapor Mode as an opt-in experiment in bounded regions, not as the default for a mission-critical app. Track the changelog at github.com/vuejs/core/releases for stability updates.
Practical Adoption Strategy
Based on Vapor Mode's characteristics, here's the recommended adoption path:
v-for with 100+ items), real-time dashboards, or animation-heavy components. This is where Vapor shines: less allocation → fewer GC pauses → smoother UX.
<script setup> + Composition API. Keep VDOM for components that rely on third-party libraries not yet compatible.
Patterns that are the best fit for Vapor Mode:
<!-- Ideal for Vapor: large list rendering -->
<script setup vapor>
import { ref } from 'vue'
const items = ref(generateLargeDataset(10_000))
const filter = ref('')
const filtered = computed(() =>
items.value.filter(item =>
item.name.toLowerCase().includes(filter.value.toLowerCase())
)
)
</script>
<template>
<input v-model="filter" placeholder="Search..." />
<div class="virtual-list">
<div v-for="item in filtered" :key="item.id" class="list-item">
<span>{{ item.name }}</span>
<span class="meta">{{ item.category }}</span>
</div>
</div>
</template>
Conclusion
Vue 3.6's Vapor Mode marks a major architectural step forward for frontend frameworks. By shifting diffing work from runtime to compile time, Vapor eliminates the Virtual DOM overhead without compromising the developer experience.
With an 83% smaller bundle (58KB down to ~10KB), 36% faster DOM manipulation, and the ability to mount 100,000 components in 100ms, Vapor Mode puts Vue alongside the highest-performance frameworks available — in the same tier as Solid.js and Svelte 5.
Most importantly: this is an evolution, not a revolution. Same codebase, same Composition API, same ecosystem — just add the vapor keyword to <script setup>. The gradual, per-component opt-in strategy lets teams adopt it without any rewrites.
References:
ClickHouse: The Real-time Analytics Engine That Queries Billions of Rows in Milliseconds
Idempotency Pattern — Designing Duplicate-Proof APIs for Distributed Systems
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.