12k
All articles

What Is Vue Vapor Mode?

Vue Vapor Mode explained: how it compiles SFCs to direct DOM ops, what changes in Vue 3.6 RC, and key pitfalls with events and slots.

OpenReplay Team
OpenReplay Team
What Is Vue Vapor Mode?

Vue Vapor Mode is a compilation mode for Vue single-file components that turns templates into direct DOM operations, so rendering and updates happen without creating or diffing a virtual DOM, which cuts baseline bundle size, update cost and memory use.

Vapor has been shown at conferences for a while now, but there is still no page about it in the Vue documentation. The detail lives in the Vue core release notes.

This article covers what Vapor Mode changes about how your components run, and where it sits in the release cycle. It also covers two behaviours in Vapor that fail silently and are easy to walk into.

Key Takeaways

  • Vapor Mode compiles Vue SFCs into direct DOM operations rather than VNode creation and diffing, which is where its smaller bundle and faster updates come from.
  • Vapor Mode is feature-complete in the Vue 3.6 release candidates but is not stable: the 3.6 line is still in pre-release, with v3.6.0-rc.9 (18 September 2026) marked Pre-release on GitHub, while the Latest label stays on the 3.5 line at v3.5.43 (17 September 2026).
  • Vapor is opt-in per component via <script setup vapor>, <script vapor> or <template vapor>, and only works on template-only SFCs and SFCs using script setup; the Options API is unsupported.
  • createVaporApp() mounts a pure Vapor app that never loads the virtual DOM runtime; installing vaporInteropPlugin to host VDOM components pulls that runtime back in and offsets the size benefit.
  • Vapor components have no VNodes and no public instance proxy, so getCurrentInstance() returns null, app.config.globalProperties does not apply, and component template refs no longer expose $el, $props, $attrs, $slots or $refs.

How Does Vue Vapor Mode Work?

The v3.6.0-rc.1 release note presents Vapor as a second way to compile single-file components, aimed at a smaller starting bundle and better performance. None of it happens by default: you turn it on yourself, one component at a time, and the part of the Vue API it covers mostly behaves the way you already expect. The source you write does not change. What changes is the output: instead of a render function that produces VNodes for the runtime to diff against the previous tree, the compiler emits code that creates the nodes once and wires each reactive dependency to the specific DOM update it controls.

Removing the virtual DOM removes three costs at once. The diffing runtime itself never has to ship in a pure Vapor app, so the baseline bundle is smaller. Updates skip VNode allocation and tree comparison, so a change to one ref touches one text node or one attribute. And because no shadow tree is retained between renders, the per-component memory footprint drops.

The component itself looks like ordinary Composition API code:

<script setup vapor>
import { ref, computed } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

<template>
  <button @click="count++">{{ count }} / {{ doubled }}</button>
</template>

Only the vapor attribute differs from the same component compiled in VDOM mode.

Release Status: Feature-Complete, Not Stable

Vapor Mode has not shipped in a stable Vue release. On the vuejs/core releases list, the 3.6 line is still in release candidates: v3.6.0-rc.9, published 18 September 2026, carries the Pre-release label, while the Latest label belongs to v3.5.43, published 17 September 2026. npm agrees: the latest dist-tag on the vue package points at 3.5.43, and the release candidates sit behind the separate rc tag. There is no 3.6.0 stable tag.

What is true is narrower and easy to conflate with shipping. The v3.6.0-rc.1 release note states that Vapor Mode is feature-complete in Vue 3.6 RC, which is why the 3.6 line moved into release candidates at all. Feature-complete describes scope, not stability. Vue’s own release policy treats every pre-release the same way: unstable, there so teams can test how a build fits their stack rather than run it in production, and free to break compatibility from one build to the next. If you install one, pin the exact version.

How Do You Opt In to Vapor Mode?

Vapor is enabled per component, not per project. Two kinds of component qualify: a single-file component that holds only a template, and one written with script setup. Components on the Options API cannot be compiled to Vapor at all. There are three ways to mark a component that does qualify: the full <script setup vapor>, its <script vapor> shorthand, and a vapor marker on the template tag, which compiles the whole file as Vapor.

<!-- Form 1: the explicit form -->
<script setup vapor>
  // ...
</script>

<!-- Form 2: shorthand for <script setup vapor> -->
<script vapor>
  // ...
</script>

<!-- Form 3: marks the whole SFC as Vapor -->
<template vapor>
  <!-- ... -->
</template>

The practical consequence is an audit step. Any component still written with data, methods or mounted has to be converted to script setup before the vapor flag will do anything for it.

Can You Mix Vapor and Virtual DOM Components?

Vapor and virtual DOM components can be mixed, and the way you mount the app decides what lands in the bundle. If every component is Vapor, mount with createVaporApp(): that path leaves the virtual DOM runtime out of the build, which is where the sharp drop in baseline size comes from. An app mounted with createApp() has to install vaporInteropPlugin before it can render a Vapor child. A Vapor app can install the same plugin to host virtual DOM children, but doing so brings the runtime back and gives up most of the size saving, as the 3.6.0-rc.1 release note explains.

// Pure Vapor: the VDOM runtime is never loaded
import { createVaporApp } from 'vue'
import App from './App.vue'
createVaporApp(App).mount('#app')

// Existing VDOM app hosting Vapor components
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
createApp(App).use(vaporInteropPlugin).mount('#app')

A component written as a render function or in JSX is a virtual DOM component too, so it needs interop inside a Vapor app. Interop is not a free pass. Nesting one mode inside the other handles ordinary props, events and slots, but not every edge case yet, and a component library built on the virtual DOM can still misbehave under Vapor. The advice from the Vue team is to give each region of an app a single rendering mode and keep mixed nesting to a minimum.

What Do Vapor Components Give Up?

A Vapor component has no VNodes and no public instance proxy. Every entry on the unsupported list in the rc.1 release note follows from that one boundary, and each item has a concrete consequence:

Unsupported in VaporWhat actually breaks
Options APIComponents using data, methods or lifecycle options cannot be compiled to Vapor at all.
app.config.globalPropertiesPlugin-injected globals are absent inside Vapor components; inject what you need explicitly.
getCurrentInstance()Returns null, so third-party code that reaches for the internal instance fails inside Vapor components.
@vue:xxx element lifecycle eventsPer-element hooks are gone; use a template ref plus onMounted.
v-memoThe manual memoization escape hatch is unavailable and must be removed.
$el, $props, $attrs, $slots, $refs on component template refsParent-reaches-into-child patterns break; move the contract to props and emits.

The note is also careful about how close that match is. Vapor sets out to behave like the virtual DOM, but the two renderers are built so differently that small mismatches in edge cases are expected, and a mismatch that small only counts as a breaking change if the old behaviour was documented.

Two Traps Worth Knowing Before You Start

Delegated Events and stopPropagation()

In the rc.1 design, events that can be delegated are dealt with at document level. The element keeps its handler, but nothing is bound to the element itself. A single listener on the document does the work: it follows the route the event takes and fires any handler it meets along the way. If an ancestor calls stopPropagation() on the way up, the event never reaches document, so that handler never runs. Three forms skip delegation and attach the listener straight to the element: @[event]="onClick", v-bind="{ onClick }" and v-on="{ click: onClick }".

<script setup vapor>
const onClick = () => save()
</script>

<template>
  <!-- the ancestor stops propagation, so a delegated handler never fires -->
  <div @click.stop>
    <button @click="onClick">Save</button>
  </div>

  <!-- binds directly to the element instead -->
  <div @click.stop>
    <button v-on="{ click: onClick }">Save</button>
  </div>
</template>

This failure mode is silent. Nothing throws, nothing logs, and error monitoring reports a healthy session while the user clicks a control that does nothing. Session replay is the technique that surfaces it, because you can watch for a click followed by no DOM mutation, which is the signature of a handler that never ran. The same applies at Vapor/VDOM boundaries, where interop gaps tend to show up as rendering oddities rather than exceptions.

Most of the churn across the RC line sits in hydration and slots, with only a couple of fixes to the way event listeners are merged, so pin an exact RC and read the minor-branch CHANGELOG for the version you install rather than assuming the rc.1 description still applies.

slots.default() Renders, It Does Not Report

In Vapor, calling slots.default() is not a free look at the slot. The call runs the slot’s rendering code, which can build Blocks and DOM nodes, set up reactive effects, and take ownership of DOM the server already sent if the page is hydrating. The common VDOM habit of calling a slot to decide whether to render a fallback therefore has consequences in Vapor.

<script setup vapor>
import { useSlots } from 'vue'
const slots = useSlots()
// Wrong: this call renders the slot rather than inspecting it
const showFallback = !slots.default?.()
</script>

Express the decision in the template and let the template own slot rendering:

<template>
  <slot>Fallback</slot>
</template>

Who Should Try Vapor Mode Now

The release note names two uses at this stage: putting Vapor into part of an app you already have, such as one page where rendering speed matters, and writing a small new app in Vapor from the start. The corollary is worth stating plainly. A project on a stable-only dependency policy, a screen built on a VDOM component library, and a codebase still on the Options API are all poor first candidates, because the first cannot install a pre-release at all and the other two sit exactly where interop and the unsupported list bite.

Planning Around Vapor Mode

Treat Vapor Mode as a compiler change you can evaluate today on one screen, not a migration you schedule. Pin an exact release candidate, convert a single list-heavy or animation-heavy page to <script setup vapor>, and check the vuejs/core releases list before you plan anything around a stable 3.6.

FAQs

Does Vapor Mode work with Nuxt?

Yes, as an experiment, and only on Vue 3.6 or newer. Nuxt keeps the root of the app on the virtual DOM and lets you mark single components or pages as Vapor, so you adopt it piece by piece. An all-Vapor Nuxt app is not possible yet, a template ref pointing at a Vapor component will not give you the element, and if the installed Vue is older, Nuxt warns and turns the option back off.

Do I need Vapor Mode to get Vue 3.6 reactivity performance improvements?

No. The 3.6 line rebuilds Vue's reactivity core on top of alien-signals, and the resulting gains in speed and memory use apply to every app on that release line, with nothing to switch on. Vapor Mode is a separate, per-component compilation change, so a standard virtual DOM app running on 3.6 already gets the reactivity improvements without enabling Vapor anywhere.

Does Vapor Mode support server-side rendering and hydration?

SSR hydration is included in the Vapor feature set of the Vue 3.6 release candidates, after early alpha builds shipped without it. Hydration correctness has been one of the heaviest areas of change across the pre-release line, so pin an exact release candidate and test server output, hydration and mismatch recovery on a real page rather than assuming parity with virtual DOM rendering.

Will a third-party component library work inside a Vapor component?

Test it before committing. Components shipped as render functions or JSX remain virtual DOM components and need interop, and library code calling getCurrentInstance or reading globalProperties finds nothing inside a Vapor component. Anything built on provide and inject rather than on the component instance usually keeps working, which is why Nuxt says most of its own composables and built-in components need no changes under Vapor.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.