writing/tutorial/2026/07
TutorialJul 13, 2026·24 min read

Vue 3.6 Vapor Mode: Build a Faster App Without the Virtual DOM

Learn how to use Vue 3.6 Vapor Mode to compile components straight to DOM operations, skipping the virtual DOM entirely. This hands-on tutorial covers Vite setup, vapor components, reactivity with alien-signals, large list rendering, VDOM interop, and measuring the real performance gains.

For a decade, the virtual DOM was the price of admission for declarative UI. You wrote a template, the framework built a tree of JavaScript objects describing the DOM, diffed it against the previous tree, and patched the differences. It worked — but it meant every update allocated objects, walked a tree, and did work proportional to the size of your component rather than the size of your change.

Vue 3.6 ships Vapor Mode, an alternative compilation strategy that removes that middle layer. A vapor component is compiled into plain JavaScript that creates DOM nodes once and then updates only the specific text nodes, attributes, and event handlers that depend on changed reactive state. No virtual DOM tree. No diffing. No component-level re-render.

The best part: it is opt-in per component. You do not rewrite your app. You mark the components on your hot paths — the giant table, the live chart legend, the virtualized list — and leave everything else alone.

In this tutorial you will build a live "operations dashboard" that renders thousands of rows and updates them on a timer, first in classic mode and then in vapor mode, so you can see the difference with your own profiler.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (Vue 3.6 tooling assumes a modern runtime)
  • Basic Vue 3 knowledge — Composition API, ref, computed, v-for
  • Familiarity with Vite and a terminal
  • A code editor with the Vue - Official extension (Volar) installed

You do not need any prior exposure to signals, compilers, or Vue internals.

What You'll Build

A single-page dashboard showing a table of 5,000 service instances, each with a status, a latency value, and a sparkline-ish load bar. A timer mutates a random slice of rows every 200 ms. You will:

  1. Scaffold a Vue 3.6 project with Vite and enable the vapor compiler
  2. Write your first vapor component and inspect the generated code
  3. Build the dashboard table and stress it with 5,000 rows
  4. Mix vapor and non-vapor components using the interop plugin
  5. Measure the update cost in Chrome DevTools and interpret the numbers

Why the Virtual DOM Costs You

Consider a component rendering one row. In classic Vue, a state change triggers the component's render function again. That function returns a fresh vnode tree — objects for the row, each cell, each text child. The runtime then walks the old tree and the new tree in parallel, compares props, and patches the real DOM where they differ.

The DOM patch at the end is tiny. Everything before it is overhead: allocation, garbage, and tree traversal proportional to the component's template size.

Vapor Mode flips this. The compiler already knows, at build time, exactly which DOM node a given expression feeds. So it emits code that wires that expression to that node directly through a reactive effect. When row.latency changes, one effect fires and one text node is written. The rest of the row is never even visited.

Two consequences fall out of this that matter more than raw speed:

  • Smaller runtime. A vapor-only app does not ship the virtual DOM renderer at all, which meaningfully shrinks the baseline bundle.
  • Update cost scales with the change, not the template. A row with 40 bindings costs the same to update as a row with 3, if only one binding actually changed.

Vue 3.6 also rebuilt the reactivity core on top of the alien-signals algorithm, which lowers the bookkeeping cost of every effect — vapor or not. Classic mode benefits too; vapor mode is what lets you cash in the full gain.

Step 1: Project Setup

Scaffold a fresh Vue project with Vite:

npm create vite@latest vapor-dashboard -- --template vue-ts
cd vapor-dashboard
npm install

Make sure you are on Vue 3.6 or newer and on a plugin version that understands vapor:

npm install vue@^3.6.0
npm install -D @vitejs/plugin-vue@latest vite@latest

Verify what you actually got — vapor support depends on the exact versions:

npm ls vue @vitejs/plugin-vue

Step 2: Enable the Vapor Compiler

Vapor compilation is a compiler feature flag, not a separate plugin. Turn it on in vite.config.ts:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
 
export default defineConfig({
  plugins: [
    vue({
      features: {
        // [!code highlight]
        vapor: true, // allow components to opt into vapor compilation
      },
    }),
  ],
})

This flag does not make every component vapor. It only tells the SFC compiler to accept the vapor marker on components that ask for it. Anything you do not mark keeps compiling exactly as before, which is why adoption can be incremental.

Step 3: Your First Vapor Component

A component opts in by adding the vapor attribute to its script block. Create src/components/StatusBadge.vue:

<script setup vapor lang="ts">
const props = defineProps<{
  status: 'healthy' | 'degraded' | 'down'
}>()
 
const LABELS = {
  healthy: 'Healthy',
  degraded: 'Degraded',
  down: 'Down',
} as const
</script>
 
<template>
  <span class="badge" :class="`badge--${props.status}`">
    {{ LABELS[props.status] }}
  </span>
</template>
 
<style scoped>
.badge {
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
}
.badge--healthy  { background: #dcfce7; color: #166534; }
.badge--degraded { background: #fef9c3; color: #854d0e; }
.badge--down     { background: #fee2e2; color: #991b1b; }
</style>

That is the entire API surface of opting in: one word, vapor, in the script tag. Props, defineProps, scoped styles, and the Composition API all work the way you already know.

There is one hard rule to internalize: vapor components are Composition API only. The Options API (data(), methods, mounted()) is a virtual-DOM-era concept and is not supported inside a vapor component. If you are on a codebase that still uses it, those components simply stay classic — which is fine.

Inspecting what the compiler emitted

This is the step most tutorials skip, and it is the one that makes vapor click. Ask Vite to dump the transformed module:

npx vite build --mode development --minify false

Then search the output chunk for your component. Instead of a render() returning createElementVNode(...) calls, you will find imperative setup code roughly along the lines of: create a span, insert a text node, and register a renderEffect that writes into that specific text node when the status prop changes. The template shape is baked into the output; only the dynamic parts remain live.

Step 4: Build the Dashboard Row

Now the component that will actually be under load. Create src/components/InstanceRow.vue:

<script setup vapor lang="ts">
import StatusBadge from './StatusBadge.vue'
import type { Instance } from '../types'
 
const props = defineProps<{ instance: Instance }>()
 
const emit = defineEmits<{
  (e: 'restart', id: string): void
}>()
 
function loadColor(load: number): string {
  if (load > 85) return '#ef4444'
  if (load > 60) return '#f59e0b'
  return '#10b981'
}
</script>
 
<template>
  <tr>
    <td class="mono">{{ props.instance.id }}</td>
    <td>{{ props.instance.region }}</td>
    <td><StatusBadge :status="props.instance.status" /></td>
    <td class="mono">{{ props.instance.latency }} ms</td>
    <td>
      <div class="bar">
        <div
          class="bar__fill"
          :style="{
            width: props.instance.load + '%',
            background: loadColor(props.instance.load),
          }"
        />
      </div>
    </td>
    <td>
      <button @click="emit('restart', props.instance.id)">Restart</button>
    </td>
  </tr>
</template>
 
<style scoped>
.mono { font-family: ui-monospace, monospace; }
.bar {
  width: 120px;
  height: 6px;
  background: #e5e7eb;
  border-radius: 3px;
  overflow: hidden;
}
.bar__fill { height: 100%; transition: width 150ms linear; }
</style>

Note that StatusBadge — itself a vapor component — is used inside another vapor component with no ceremony. Vapor-to-vapor composition is the fast path, and you should aim for whole subtrees to be vapor rather than alternating.

Define the shared type in src/types.ts:

export interface Instance {
  id: string
  region: string
  status: 'healthy' | 'degraded' | 'down'
  latency: number
  load: number
}

Step 5: Generate and Mutate the Data

Create src/composables/useInstances.ts. This is plain Composition API — nothing vapor-specific, because reactivity is shared between both modes:

import { shallowRef, onUnmounted } from 'vue'
import type { Instance } from '../types'
 
const REGIONS = ['tunis-1', 'paris-2', 'riyadh-1', 'dubai-3', 'frankfurt-1']
const STATUSES = ['healthy', 'healthy', 'healthy', 'degraded', 'down'] as const
 
function makeInstance(i: number): Instance {
  return {
    id: `svc-${String(i).padStart(5, '0')}`,
    region: REGIONS[i % REGIONS.length],
    status: STATUSES[i % STATUSES.length],
    latency: 20 + Math.floor(Math.random() * 180),
    load: Math.floor(Math.random() * 100),
  }
}
 
export function useInstances(count = 5000, tickMs = 200) {
  // shallowRef: we replace the array reference, not deep-track 5000 objects
  const instances = shallowRef<Instance[]>(
    Array.from({ length: count }, (_, i) => makeInstance(i)),
  )
 
  // Mutate ~2% of rows on every tick to simulate a live feed
  const timer = setInterval(() => {
    const next = instances.value.slice()
    const churn = Math.max(1, Math.floor(count * 0.02))
 
    for (let n = 0; n < churn; n++) {
      const idx = Math.floor(Math.random() * next.length)
      const row = next[idx]
      next[idx] = {
        ...row,
        latency: 20 + Math.floor(Math.random() * 180),
        load: Math.floor(Math.random() * 100),
        status: STATUSES[Math.floor(Math.random() * STATUSES.length)],
      }
    }
 
    instances.value = next
  }, tickMs)
 
  onUnmounted(() => clearInterval(timer))
 
  return { instances }
}

Using shallowRef here is deliberate. Deeply reactive proxies over 5,000 objects cost memory and setup time in either rendering mode; replacing immutable row objects keeps the reactive graph shallow and lets each row's bindings re-evaluate only when its own object identity changes.

Step 6: Compose the Table

Create src/components/InstanceTable.vue:

<script setup vapor lang="ts">
import { computed } from 'vue'
import InstanceRow from './InstanceRow.vue'
import { useInstances } from '../composables/useInstances'
 
const { instances } = useInstances(5000, 200)
 
const unhealthy = computed(
  () => instances.value.filter((i) => i.status !== 'healthy').length,
)
 
function onRestart(id: string) {
  console.info('restart requested', id)
}
</script>
 
<template>
  <header class="summary">
    <strong>{{ instances.length }}</strong> instances ·
    <strong>{{ unhealthy }}</strong> unhealthy
  </header>
 
  <table>
    <thead>
      <tr>
        <th>ID</th><th>Region</th><th>Status</th>
        <th>Latency</th><th>Load</th><th></th>
      </tr>
    </thead>
    <tbody>
      <InstanceRow
        v-for="instance in instances"
        :key="instance.id"
        :instance="instance"
        @restart="onRestart"
      />
    </tbody>
  </table>
</template>

v-for, :key, computed, and event emits behave identically to what you know. The :key is not optional here — vapor's list reconciliation relies on it to reuse the DOM blocks it already built for stable identities.

Step 7: Mount a Vapor App

For an app whose root and children are entirely vapor, mount it with createVaporApp, which does not pull in the virtual DOM renderer at all:

// src/main.ts
import { createVaporApp } from 'vue'
import App from './App.vue'
import './style.css'
 
createVaporApp(App).mount('#app')

App.vue then needs its own vapor marker:

<script setup vapor lang="ts">
import InstanceTable from './components/InstanceTable.vue'
</script>
 
<template>
  <main>
    <h1>Operations Dashboard</h1>
    <InstanceTable />
  </main>
</template>

Run it:

npm run dev

You should see 5,000 rows, with latencies and load bars flickering as the timer churns them.

Step 8: Interop With Classic Components

In the real world you will not convert everything. You have a design-system dialog, a charting wrapper, a third-party component from npm — all of them classic virtual-DOM components. Vue supports mixing the two, but the bridge has to be installed explicitly.

To render a vapor component inside a classic app, install the interop plugin on a normal createApp:

// src/main.ts — classic root, vapor leaves
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
 
const app = createApp(App)
app.use(vaporInteropPlugin) // enables VDOM ↔ vapor boundaries
app.mount('#app')

With the plugin installed, a classic parent can render a vapor child, and a vapor parent can render a classic child, in both directions. Note that createVaporApp and createApp + vaporInteropPlugin are two different strategies:

StrategyUse whenTrade-off
createVaporAppGreenfield app, every component is vaporSmallest runtime; no classic components allowed anywhere
createApp + vaporInteropPluginExisting app, converting hot pathsShips both runtimes; lets you migrate one component at a time

The practical guidance is simple: each interop boundary has a cost. Crossing from VDOM to vapor and back on every row of a table wipes out the win. Convert contiguous subtrees — the table, its rows, its badges — and let the boundary sit at the top, where it is crossed once.

For a classic component that has no SFC file (a render function, say), you can still author it in vapor style with defineVaporComponent, but for most applications, converting SFCs by adding the marker is all you will do.

Testing Your Implementation

Two checks matter, and only one of them involves a stopwatch.

Correctness

Confirm nothing broke functionally. Click a Restart button and verify the emit reaches the console. Confirm the unhealthy counter tracks the badges. Confirm rows update in place rather than reordering, which would mean your :key is wrong.

Performance

Open Chrome DevTools, go to the Performance panel, and record roughly 5 seconds of the dashboard running. Then compare two builds:

  1. Remove vapor from the script tags in StatusBadge.vue, InstanceRow.vue, and InstanceTable.vue, switch main.ts back to createApp, and record.
  2. Put the markers back, switch to createVaporApp, and record again.

Look for three things in the flame chart:

  • Scripting time per tick. In classic mode you will see repeated render-function frames and patch work for every changed row's subtree. In vapor mode those frames largely vanish; what remains is a handful of narrow effect callbacks.
  • Memory / GC. Classic mode allocates a fresh vnode tree per update. Watch the sawtooth in the memory track flatten out under vapor.
  • Initial mount. Vapor's mount is typically faster too, because it is running straight-line DOM construction code rather than building a tree and then walking it.

Do not chase a specific multiplier. The size of the win depends entirely on how binding-heavy your templates are and how much of the app you converted. On a table like this one, with many rows and few changed bindings per row, the delta is large. On a component that redraws everything anyway, it is small.

Also make the comparison honest: build for production (npm run build && npm run preview) before measuring. Dev-mode Vue carries warnings and dev-only bookkeeping that distort both modes.

Troubleshooting

"The vapor attribute is ignored / component still uses the virtual DOM." The features: { vapor: true } flag is missing from your @vitejs/plugin-vue options, or your plugin version predates vapor support. Re-check npm ls @vitejs/plugin-vue.

"Cannot use Options API in a vapor component." Exactly what it says. Convert the component to script setup with the Composition API, or leave it classic and let the interop plugin bridge it.

A classic component renders blank inside a vapor parent. You forgot app.use(vaporInteropPlugin). Interop is never implicit.

A third-party library component throws when placed under vapor. Some libraries reach into virtual-DOM internals (custom render functions, vnode manipulation, certain directives). Keep those components classic and put the interop boundary above them rather than fighting it.

Performance got worse after converting. Almost always an interop-boundary problem: you converted a leaf component that sits inside a classic list, so every item now pays a boundary crossing. Convert upward until the boundary is crossed once, not N times.

A feature you rely on is unsupported. Vapor Mode is new and its feature surface is still being completed release by release. Check the current Vue documentation before assuming a directive, transition, or lifecycle behavior is available inside vapor components — and keep the affected component classic in the meantime.

Next Steps

  • Virtualize the list. Vapor makes each update cheap; rendering 5,000 DOM rows at all is still expensive. Pair vapor with a windowing library and the two wins compound.
  • Convert your worst offender first. Profile your real app, find the component with the highest scripting cost per interaction, and convert that subtree — not your whole codebase.
  • Explore the compiler output. Reading the generated code for a template you wrote yourself is the fastest way to build intuition about which bindings are actually dynamic.
  • Read our related guides on React Compiler's automatic optimization and building fullstack apps with Nuxt 4 to see how other frameworks are attacking the same overhead from different angles.

Conclusion

Vapor Mode is not a new framework, a new mental model, or a rewrite. It is the same Vue you write today — ref, computed, v-for, SFCs, scoped styles — compiled through a different backend that emits direct DOM operations instead of a virtual DOM tree.

What you learned here transfers directly to production work: enable the compiler flag, mark contiguous subtrees with vapor, keep those subtrees Composition-API-only, install vaporInteropPlugin at the boundary with your classic code, and verify the win in a production build with a profiler rather than trusting a benchmark someone else ran.

Start with the one component your users feel the most. That is where the virtual DOM was costing you, and that is where removing it will show.