Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

Vue Vapor Mode Explained: What It Is, What Breaks, and Whether to Care Yet (2026)

Your Nuxt 3 app is on dead software. Vue 3.6’s headline feature is worth knowing about.

Nuxt 3 reached end of life on 31 July 2026, which means your Nuxt 3 app is officially running on unsupported software. No more security patches, no more fixes. The way forward is Nuxt 4 — and Nuxt 4.6, released on 5 October 2026, ships with support for the most interesting thing to happen to Vue in years: Vue Vapor Mode, a compilation mode that throws the virtual DOM away entirely and updates the real DOM directly.

As of today, 16 October 2026, Vue 3.6 is still a release candidate — the latest pre-release is 3.6.0-rc.10 (30 September), while 3.5.43 remains the stable line. So Vapor Mode is not something you deploy to production this week. But it is feature-complete, it is opt-in per component, and understanding it now means you will know exactly when and where to use it the moment 3.6 goes stable. Here is the honest, verified picture.

What Vue Vapor Mode actually does

You already know how Vue works today. When reactive state changes, Vue re-runs your render function, builds a fresh tree of virtual nodes (plain JavaScript objects describing the UI), diffs it against the previous tree, and only then touches the real DOM. The virtual DOM is the clever middleman that makes declarative UI work — but it is also a middleman, and middlemen cost you.

In Vue Vapor Mode, the compiler analyses your template at build time and generates code that manipulates the DOM directly. There are no virtual nodes, no diffing at runtime. When a reactive value changes, only the part of the DOM bound to it is updated. You write the exact same template syntax and the exact same Composition API — the output of the compiler is what changes.

Take this ordinary component:

<script setup vapor lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

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

That single vapor attribute is the whole opt-in. In classic Vue, clicking that button re-renders the component, produces a new vnode tree, diffs it, and patches the text child. In Vapor Mode, the compiler has already worked out at build time that count controls exactly one text node, so it binds the reactive value straight to that node. No tree is walked. Nothing is compared. One targeted DOM write.

The official Vue 3.6 release notes put it plainly: Vapor Mode is 100% opt-in, and it exists to reduce baseline bundle size and improve performance. The notes report that Vapor reached the same level of performance as Solid and Svelte 5 in third-party benchmarks (the well-known js-framework-benchmark suite). Treat that the way you should treat any framework benchmark — the scenario measured decides the result — but the direction is real.

Even if you never touch Vapor, Vue 3.6 makes everything faster

Vue 3.6 is not just Vapor. It also ships a major refactor of @vue/reactivity built on the alien-signals library. This is an internal change: ref, reactive, computed and watch keep exactly the same API, but the reactivity engine underneath is faster and uses less memory.

The important part: you get this for free. It is part of Vue 3.6 itself, so every component — Vapor or not — benefits the moment you upgrade, with zero code changes. If you are the cautious type who will wait for the stable release, this alone is a reason to plan the upgrade rather than dread it.

How to enable Vapor in a Vue app

Vapor works with single-file components that use <script setup> or only a template. You mark the component with the vapor attribute, and there are three equivalent spellings:

<script setup vapor> ... </script>
<script vapor> ... </script>          <!-- shorthand for <script setup vapor> -->
<template vapor> ... </template>       <!-- compiles the whole SFC in Vapor mode -->

The Options API is not supported — Vapor is Composition API territory only. In a classic createApp() project, you also need to install the vaporInteropPlugin so Vapor and regular components can live in the same tree:

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

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

With the plugin installed, Vapor and virtual-DOM components can nest inside each other, though the Vue notes advise keeping distinct regions of the app in one mode each rather than mixing nesting aggressively — edge cases around props, events and slots are still being ironed out in the RCs.

If every component in your app is Vapor, you can go further with createVaporApp(), which keeps the virtual-DOM runtime out of your bundle entirely:

import { createVaporApp } from 'vue'
import App from './App.vue'

createVaporApp(App).mount('#app')

Note the trade-off: every component still needs the vapor marker, and any render-function or JSX component is a virtual-DOM component that needs interop — which pulls the runtime back in and eats into your bundle savings.

Vapor in Nuxt 4.6: interop, not a rewrite

Nuxt 4.6 added Vapor support in interop mode (nuxt/nuxt #35759). Your app root stays on the virtual DOM, and you opt individual components or pages into Vapor. Two steps:

First, flip the switch in your config:

export default defineNuxtConfig({
  vue: {
    vapor: true,
  },
})

Then mark whichever components should use Vapor with <script setup vapor>, exactly as in the plain-Vue example above.

The practical requirements: Nuxt needs Vue ^3.6.0-rc.2 or newer, and it recommends trying Vapor in a fresh project first. The good news is that the framework did the hard compatibility work for you — routing, useAsyncData, layouts and most built-in components keep working unchanged, because Nuxt taught its auto-import loader, useAsyncData, definePageMeta and slot inspection about Vapor, sent fixes upstream to Vue, and added a Vapor test suite that tracks what is supported.

Interop mode does come with a known-limitations list, and you should read it before touching production code. The notable ones:

  • The app root cannot be Vapor — fully Vapor apps are not available in Nuxt. This is per-component opt-in only.
  • Vapor components do not expose $el on template refs.
  • <ClientOnly> skips its attribute forwarding, and <NuxtIsland>, server components, and the head components that read text children (<Title>, <Style>, <Noscript>) warn if you pass them a Vapor slot.
  • Keyed onPrehydrate falls back to the unkeyed form, because there is no component instance to attach the key to.
  • asyncData and fetchKey through defineNuxtComponent do not work — use useAsyncData in <script setup> instead.
  • Nuxt composables called after an await may lose their context.

What Vue Vapor Mode cannot do yet

Vapor supports a subset of Vue’s APIs. The official notes list what is unavailable or simply does not apply inside Vapor components, and the list is the make-or-break section for most teams:

  • The Options API — data, methods, mounted and friends do not exist in Vapor.
  • app.config.globalProperties — the global-properties escape hatch is gone.
  • getCurrentInstance() returns null. If a library you use reaches for the current instance, it will break — this is the single biggest compatibility risk with component libraries.
  • Per-element lifecycle events such as @vue:mounted.
  • v-memo — which makes sense, since there is no diffing to memoise.
  • $el, $props, $attrs, $slots and $refs on component template refs.

Anything that depends on virtual nodes or the component public-instance proxy is out by design. Component libraries built on the virtual DOM deserve testing in your own project: the Vue notes mention “rough edges” with VDOM component libraries inside Vapor without naming names. The official line is that Vapor has reached feature parity with stable VDOM features — <Suspense> is the known exception, unsupported in Vapor-only mode, though you can render Vapor components inside a VDOM <Suspense>. The RC changelog also shows ongoing work on Vapor-plus-Suspense integration and several Vapor hydration fixes, so treat SSR apps with extra care and test hydration thoroughly.

Two smaller API changes deserve a mention because they will bite you in code review. First, event handling: in earlier RCs, Vapor delegated events to document, which meant a stopPropagation() on an ancestor could swallow a child’s handler. Since rc.2, listeners attach directly to the element, just like regular Vue — and delegation is now opt-in through a .delegate modifier:

<button @click.delegate="onClick" />

Second, custom directives get a different interface. Instead of an object with lifecycle hooks, you write a function that receives the element and a reactive getter, and it can return a cleanup function:

import { watchEffect } from 'vue'

const MyDirective = (el, source) => {
  watchEffect(() => {
    el.textContent = source()
  })
  return () => console.log('cleanup')
}

The full signature is (node, value?, argument?, modifiers?), where value is the reactive getter.

Where Vapor actually earns its keep

Now the honest question: will you feel it? The gains show up where an app does a lot of DOM update work — dense lists and tables, components that change several times a second, and pages where initial JavaScript size genuinely matters.

The most concrete public numbers come from a Frontend Nation 2026 talk by Nico Devs (Tighten): updating one block in a 10,000-block grid took about 16 ms in Vue 3.5, 10 ms in Vue 3.6 without Vapor, and about 0.2 ms with Vapor on. His test apps used three to ten times less memory in Vapor Mode. These are demos on pre-release builds, so read them as direction rather than a guarantee — but they line up with the Vue team’s own beta notes.

For an ordinary CRUD app, your users will likely not notice the difference. And Vapor does not fix problems that do not come from the renderer: rendering 10,000 items into the DOM is slow in both modes, and the fix is still to virtualise the list. Calling heavy functions straight from the template — {{ calculateTotal(item) }} — is still a bad idea in both modes.

Nuxt 4.6’s own headline performance numbers are worth quoting precisely because they are not Vapor numbers: nuxt build on a starter project went from 4.4 s to 3.6 s (18% faster), and a page with 300 <NuxtLink>s went from 110 to 140 requests per second in SSR (+26%). The release notes credit those to general optimisations in the release, not to Vapor — which tells you something important about the Nuxt team’s own view of where Vapor fits. It is a scalpel, not a default.

The upgrade decision, honestly

Here is how this lands for the three kinds of reader this site serves.

If you are still on Nuxt 3: this is the deadline you have been putting off. Nuxt 3 hit end of life on 31 July 2026, and there will be no security releases alongside Nuxt 4.6. The upgrade guide is waiting. Nuxt 4.6 requires Node ^22.22.3, ^24.15.0 or >=26.0.0, so check your CI and hosting first. Get to 4.6 for the security story; treat Vapor as a separate, later experiment.

If you are already on Nuxt 4: upgrading to 4.6 is the low-risk move — most of what is in it is on the Nuxt 5 branch already, and you can preview Nuxt 5 defaults with future.compatibilityVersion: 5. Enable the Vapor switch on a branch, mark one measured-hot component, and compare. That is the entire experiment.

If you are deciding whether to care about Vapor at all: the Vue team’s own guidance is the one to follow. They recommend two uses for now — applying Vapor to parts of an existing app, such as a performance-sensitive page, and building small new apps entirely in Vapor mode. For a production app that already runs well on Vue 3.5, waiting for the stable release is the safer choice.

Try it in an afternoon: the five-step plan

If you have a component you have already measured and found slow, here is the contained way to test Vapor without risking your app:

  1. Measure first. Open the browser’s Performance panel, record an interaction on the slow component, and confirm the bottleneck is rendering, not network or compute.
  2. Isolate it. Put the component behind a branch, on one route. Confirm it uses <script setup> and does not depend on anything in the cannot-do list above — no getCurrentInstance(), no Options API, no v-memo.
  3. Enable Vapor in Nuxt 4.6 with vue: { vapor: true } and mark the component with <script setup vapor>. Run your UI library’s components through the same page and watch for the rough edges.
  4. Measure again with the same interaction, same machine, same build. Compare update timings and memory, not vibes.
  5. Back it out if it does not pay. The whole point of per-component opt-in is that removing the vapor attribute returns you to exactly where you started. Keep the feature branch until Vue 3.6 goes stable, then re-run the numbers.

Vue has shipped a virtual DOM since Vue 2 and built one of the most successful frontend ecosystems on it. That the same team is now shipping a compiler that skips it entirely — opt-in, one attribute, no rewrite — is the most honest kind of performance work: the kind that lets you adopt it exactly where you have measured a problem, and ignore it everywhere else.

Further Reading & References

Leave a Comment