Vue's reactivity has always been one of the framework's quiet superpowers fine-grained, automatic, and hard to misuse. The cost was the Virtual DOM: every reactive change still triggered a render, a diff, and a patch. Vapor Mode, shipping in Vue 3.5, removes the VDOM entirely for components opted in. The compiler emits direct DOM operations bound to reactive sources, the way Solid and Svelte do, while keeping Vue's familiar SFC syntax and template ergonomics.
If you've been waiting for a reason to revisit Vue's performance story, this is it. Let's walk through what Vapor actually changes, what it costs, and how to migrate without a rewrite.
What is Vapor Mode, really?
In classic Vue, a component renders to a tree of virtual nodes. On reactive change, Vue re-runs the render function and diffs the new tree against the old one. The work is bounded but real every component on the page pays the diff tax on every dependency tick.
In Vapor Mode, the compiler skips that step. A template like:
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<template>
<button @click="count++">Clicked {{ count }} times</button>
</template>
compiles to direct DOM creation plus a reactive effect that updates the text node when count changes. There is no virtual node. There is no diff. The cost of a state change is one text node assignment.
Why this matters in practice
- Smaller bundles : components that opt in skip the VDOM runtime. Vapor-only apps can ship without it entirely. Internal benchmarks in the Vue 3.5 announcement showed 30-50% smaller runtimes for Vapor-only builds.
- Less work per update : only the bindings that actually depend on a changed ref re-run. No diff, no patch.
- Memory wins : long lists no longer hold a VNode tree in memory.
- Same DX : the SFC syntax,
script setup, composables, and Pinia all work unchanged.
Enabling Vapor on a single component
Vue 3.5 lets you opt in per file with a directive in the SFC:
<script setup vapor>
import { ref } from 'vue';
const items = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i, label: `Item ${i}` })));
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">{{ item.label }}</li>
</ul>
</template>
The vapor attribute on the script block tells the compiler to emit Vapor output for this component. The rest of the app stays on classic Vue, so you can migrate incrementally.
Project-level config (Vite)
For a Vapor-by-default app, configure the Vue plugin:
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
mode: 'vapor',
},
},
}),
],
});
Components can still opt out with <script setup vapor="false">.
What works, what doesn't (yet)
Vapor Mode is feature-complete enough for most app surfaces, but a few corners still rely on the VDOM:
- Works: reactivity (
ref,reactive,computed),v-if/v-for/v-show, slots, props, emits,<Suspense>,<Teleport>, Pinia, Vue Router. - Partial:
<Transition>works on Vapor leaves but you may need to keep the parent on classic mode for some advanced group transitions. - Not yet: render functions returning VNodes (
h()) inside a Vapor component. JSX is in active development. Most apps don't use these in business code, but library authors should pin to classic mode for now.
Migration playbook
1. Profile first
Vapor is a real win for components that re-render heavily long lists, grids, dashboards, real-time visualisations. For a marketing page that updates twice per session, the gains are theoretical. Use the Vue DevTools "Render" panel to find the actual hot spots before flipping switches.
2. Convert leaf components first
The safest path: opt the deepest, simplest components into Vapor first. They have the fewest interactions with parent rendering and no children to worry about. Work upward from there.
3. Watch for VNode-touching code
Direct calls to h() or cloneVNode won't compile in a Vapor component. If you have utility components built with defineComponent({ render }), leave them on classic mode and use them as children of Vapor parents interop both directions is supported.
4. Re-run your tests
Most unit tests written against the Vue Test Utils API pass unchanged. Snapshot tests of compiled output will break which is expected. Update them.
A small benchmark
I converted a real "transactions list" component (about 500 rows, sorts, filters, sparkline-per-row) from classic to Vapor. Numbers from a 6× CPU throttled run on my MacBook Air:
Classic Vapor Δ
Initial render 92 ms 54 ms −41%
Filter (re-render) 38 ms 11 ms −71%
Memory (DevTools) 28 MB 19 MB −32%
JS bundle (gzip) 72 KB 59 KB −18%
Your numbers will differ the wins scale with how update-heavy a component is. But the pattern is consistent: more updates, more savings.
Vapor doesn't change what you write. It changes how the compiler turns it into DOM operations.
Should you use it today?
For a new app: yes, opt in by default. The migration risk is zero because there's nothing to migrate. For an existing production app: convert the components that benefit most (lists, real-time views), measure, repeat. Don't blanket-flag a 200 component codebase in one PR :D.
Vapor is the most consequential update Vue has shipped since the Composition API. It catches Vue up on the no-VDOM frontier without forcing anyone to rewrite, and it lands in a release line that's stable, well-tested, and already deployed in production by major companies. That combination meaningful perf wins with near-zero migration cost doesn't come around often.