Skip to content
Deep diveAdvanced Vue

Vapor Mode, explained without the hype

Vapor Mode is a second way for Vue to compile the same single-file component. It skips the virtual DOM, so whether it helps depends on how much your app was paying for one.

vuejs.school team24 Sep 20267 min readVue 3.5.43 stable · 3.6.0-rc.9

Vapor Mode is the headline feature of Vue 3.6, and most of what you read about it is benchmark charts. Charts measure someone else's app. To know whether Vapor would speed up yours, you need to see what it changes in the code Vue runs, and then measure your slowest page with and without it.

What the virtual DOM does on every update

Here is the component we will compile both ways:

src/components/ClickCounter.vue
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button type="button" @click="count++">Clicked {{ count }} times</button>
</template>

In normal mode, the compiler turns the template into a render function. Each time it runs, it returns a description of the UI made of plain JavaScript objects called virtual nodes, or vnodes. Vue keeps the previous description, compares it with the new one (the "diff"), and changes only the real DOM nodes that differ (the "patch"). @vue/compiler-sfc 3.6.0-rc.9 turns this template into the following, trimmed for reading:

ClickCounter.vue compiled for the virtual DOM
return (_ctx, _cache) => {
  return (_openBlock(), _createElementBlock('button', {
    type: 'button',
    onClick: _cache[0] || (_cache[0] = $event => (count.value++)),
  }, 'Clicked ' + _toDisplayString(count.value) + ' times', 1 /* TEXT */))
}

Vue's virtual DOM is already helped by the compiler. The 1 /* TEXT */ is a patch flag: it tells the runtime that only the text of this element can change, so the diff skips the attributes. Still, every click reruns this function, allocates a new vnode, and compares it with the old one before the single text node is touched.

What Vapor compiles to instead

Add vapor to the script tag, <script setup vapor lang="ts">, and the same compiler turns that template into this:

ClickCounter.vue compiled in Vapor Mode
const t0 = _template('<button type=button> ', 1)

// inside setup, after `const count = ref(0)`
const n0 = t0()
const x0 = _txt(n0)
_on(n0, 'click', () => (count.value++))
_renderEffect(() => _setText(x0, 'Clicked ' + _toDisplayString(count.value) + ' times'))
return n0

Read it line by line. _template parses the static HTML once; calling t0() clones it, which is how the real <button> is created. _txt(n0) grabs the text node inside it. _on attaches the click listener straight to that element. _renderEffect sets up a reactive effect: it reads count.value, so when count changes, Vue reruns just this one line, and _setText writes the new string into that one text node.

There is no render function to rerun, no vnode and no diff. The work of figuring out what can change moved from runtime to compile time, and the output is a list of direct DOM operations, each tied to the reactive values it reads. That is the whole idea, and also why Vapor supports only the parts of Vue that can be compiled this way.

Opting in and mixing with normal components

Vapor is opt-in per component. The rc.1 release notes, which declared the intended Vapor feature set complete, list three markers:

  • <script setup vapor>, the form used above
  • <script vapor>, a shorthand for the same thing
  • <template vapor>, which compiles the whole file in Vapor Mode, useful for template-only components

Only <script setup> and template-only components can be Vapor components. To use them inside an existing app created with createApp(), install the interop plugin:

src/main.ts
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App).use(vaporInteropPlugin).mount('#app')

With the plugin, Vapor and normal components can be nested inside each other, with standard props, events and slots working across the boundary. The notes add a caveat: interop "does not yet account for all possible edge cases", and a component library built on the virtual DOM may have rough spots inside Vapor. They recommend distinct regions, such as one page in Vapor and the rest unchanged, rather than mixing the two at every level of the tree.

An app made entirely of Vapor components can start with createVaporApp(App) instead, which leaves the virtual DOM runtime out of the bundle. Components written with render functions or JSX stay virtual DOM components, so a Vapor app that uses one needs the interop plugin too, and that brings the virtual DOM runtime back.

What you give up

The rc.1 notes list the features that are unsupported or do not apply in Vapor components:

  • the Options API
  • app.config.globalProperties
  • getCurrentInstance(), which returns null
  • per-element lifecycle events such as @vue:mounted
  • v-memo
  • $el, $props, $attrs, $slots and $refs on a component template ref

Two more differences change how you write code:

  • Custom directives use a new, smaller interface: a function that receives the element and a getter for the value, and may return a cleanup function. An old object directive with mounted and updated hooks does not run in a Vapor template; in development, Vue logs a warning instead.
  • Slots are not safe to call from script. Calling slots.default() to see whether a slot has content actually renders it in Vapor, which can create DOM nodes and effects. Leave slot rendering to the template.

Event delegation became opt-in in rc.2

Delegation means one listener on document runs the handlers for many elements, instead of one listener on each element. In rc.1, Vapor delegated events this way by default, so an ancestor calling stopPropagation() could stop a child's handler from running, because the event then never reached document.

rc.2 made delegation opt-in. Listeners now attach to the element, as in normal mode, and you add the Vapor-only .delegate modifier (@click.delegate) where you want delegation. If your config still sets compilerOptions.eventDelegation from an earlier pre-release, remove it: rc.2 removed the option.

When Vapor helps and when it will not

Vapor removes the cost of the virtual DOM. It helps where that cost is a real part of your app's time and memory:

  • Update-heavy UI. A table where many cells change every second, a live chart, a large form where each keystroke updates derived values. Each update becomes a few direct writes instead of a rerender and a diff.
  • Memory. A Vapor component keeps no vnode tree between renders, only its DOM nodes and the effects that update them. A screen with thousands of components holds less.
  • Baseline bundle size. A pure Vapor app built with createVaporApp does not ship the virtual DOM runtime. The notes call the reduction drastic. An app that installs the interop plugin keeps that runtime, so this benefit applies to new, small, all-Vapor apps.

It will not matter when your time goes elsewhere. If a page is slow because of a 2 MB image, a slow API or a waterfall of requests, Vapor changes nothing you can see. A mostly static page that renders once and rarely updates gains little.

Part of the speed-up in 3.6 is not Vapor at all. The reactivity system was rewritten on top of the alien-signals library, which the notes say significantly improves the performance and memory use of reactivity itself, for every component, Vapor or not. A fair measurement separates the two.

How to measure before you switch

Measure the page you think is slow, with a production build (npm run build && npm run preview in a Vite project), because development builds include warnings and devtools hooks that distort the numbers. Take the same three readings each time:

  • Interaction time. In Chrome's Performance panel, record the slow interaction three times and note the scripting time.
  • Memory. In the Memory panel, take a heap snapshot after the page has settled and note the total size.
  • Bundle size. Note the size vite build prints for the main chunk.

Take them in three rounds:

  1. Stable Vue 3.5, on your main branch. This is the baseline.
  2. The RC with no Vapor components, on a new branch (commands below). Any change from round 1 comes from the upgrade, not from Vapor.
  3. The RC with one Vapor page. Convert the components on that page to <script setup vapor>, install vaporInteropPlugin as shown earlier, and measure again.

These commands set up round 2:

git switch -c try-vapor
npm pkg set overrides.vue='$vue'
npm install vue@3.6.0-rc.9 --save-exact
npm run build && npm run preview

What the two middle lines do:

  • npm pkg set adds an override to package.json. @vitejs/plugin-vue (and Pinia and Vue Router, if you use them) lists Vue as a peer dependency with a range such as ^3.2.25, and npm does not count a pre-release as inside a range like that, so without the override npm stops with an ERESOLVE peer conflict. $vue means "the version in my own dependencies"; the single quotes keep the shell from reading it as a variable.
  • --save-exact writes 3.6.0-rc.9 rather than a range, so the next RC arrives only when you choose it.

For a new project, npm create vue@latest offers "Vue 3.6 (Release Candidate)" under its experimental features and writes the overrides for you.

If the numbers improve from round 2 to round 3, you have your answer for that page. If they do not, the bottleneck was never the virtual DOM, and you have saved yourself a migration.

Check it stuck3 questions from the Performance and Vapor Mode quiz.
Practice now
Was this helpful?

Keep reading