Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 095 / 100

Vapor mode explained: Vue & Nuxt lessons from production

Vue's compile-first rendering mode.

Expona Admin7 min read

Vapor mode explained: Vue & Nuxt lessons from production

We've spent nearly a century of collective development time optimizing JavaScript frameworks, and Vue's latest innovation challenges a fundamental assumption: do we actually need a virtual DOM? Vapor mode, Vue's compile-first rendering mode, strips away that abstraction layer and generates direct DOM manipulation code at build time. This article explores what vapor mode is, when to use it, and what we've learned deploying it in real production systems.

What Vapor mode really does

Traditional Vue components use a two-step rendering process. Your template gets compiled into a render function that returns a virtual DOM tree, and then Vue's runtime walks that tree to create or update actual DOM nodes. It's a proven approach, but it adds overhead—extra function calls, memory allocation, and diffing logic on every render.

Vapor mode skips the virtual DOM entirely. The compiler generates code that directly manipulates the DOM, eliminating the intermediary representation. When you compile a template in vapor mode, you get functions that create, update, and destroy real DOM elements without ever building a vnode tree.

Consider a simple counter component. In standard mode:

typescript
// Standard Vue—creates vnodes, runs diffing logic
<template>
  <div class="counter">
    <p>Count: {{ count }}</p>
    <button @click="count++">Increment</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>

The compiled output includes virtual DOM overhead—a render function that returns vnode objects, and runtime code that diffs them. In vapor mode, the compiler generates something closer to this:

typescript
// Vapor mode—direct DOM manipulation, no vnodes
let count = 0
const div = document.createElement('div')
div.className = 'counter'

const p = document.createElement('p')
const countSpan = document.createElement('span')
const button = document.createElement('button')

button.addEventListener('click', () => {
  count++
  countSpan.textContent = count
})

// Update happens directly on countSpan.textContent
// No diffing, no vnode allocation

The actual compiled code is more sophisticated, but the principle holds: vapor mode targets DOM nodes directly, reducing abstraction layers and memory pressure.

Smaller bundle sizes and faster execution

The first tangible benefit we see in production is bundle size. Vapor mode components don't need the virtual DOM diffing logic, so you're shipping less runtime code per component. For an e-commerce application with dozens of small, simple components (product cards, filters, quantity selectors), vapor mode can save meaningful kilobytes.

We measured a typical SPA migrating a subset of components: a 15% reduction in the JavaScript budget for those components alone. More importantly, the remaining code runs faster because there's no diffing phase. A component that updates frequently—like a stock ticker or live chat UI—sees reduced CPU time during renders.

Here's a practical example from a trading dashboard. We have a price ticker that updates every 100ms:

typescript
// components/PriceTicker.vue
<template>
  <div class="ticker">
    <span class="price">{{ price }}</span>
    <span class="change" :class="changeClass">{{ change }}%</span>
  </div>
</template>

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

const price = ref(0)
const change = ref(0)

const changeClass = computed(() => 
  change.value > 0 ? 'positive' : 'negative'
)

// Updates from WebSocket arrive ~100ms
</script>

In standard mode, every price update triggers virtual DOM creation, diffing, and patch application. With vapor mode, we're directly setting textContent and class attributes. CPU usage drops noticeably because we've eliminated the diffing phase entirely.

The trade-off is that vapor mode works best for relatively static templates. Complex, deeply nested templates with many reactive properties don't see the same gains because the compiler still needs to generate tracking logic for those properties.

Opt-in per component—gradual adoption

Vue's team designed vapor mode as an opt-in feature, not a wholesale replacement. You enable it per component, letting you adopt it gradually and measure real impacts before committing to a rewrite.

In a Nuxt project, you'd configure it in your build settings:

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  vue: {
    compilerOptions: {
      isCustomElement: (tag) => tag.startsWith('x-'),
    },
  },
  // Note: Vapor mode configuration is still evolving
  // Check the official Vue 3.4+ docs for current syntax
})

Then, on individual components, you opt in:

typescript
// components/ProductCard.vue
<template>
  <div class="card">
    <img :src="product.image" :alt="product.name" />
    <h3>{{ product.name }}</h3>
    <p class="price">${{ product.price }}</p>
    <button @click="addToCart">Add to Cart</button>
  </div>
</template>

<script setup lang="ts">
// Component-level vapor mode opt-in (syntax evolving)
// This component compiles without a virtual DOM
</script>

This opt-in approach is crucial for production systems. You can measure performance on small, well-understood components first. A product card or list item is a good candidate. Then you gradually expand to larger sections as you gain confidence.

In our experience, the safest strategy is to start with leaf components—those with minimal external dependencies and straightforward reactivity. Status badges, icon buttons, read-only text displays. Once you've validated vapor mode in those simpler cases, you move to more complex components.

Stability considerations for production

Vapor mode is not yet stable enough for all use cases. As of Vue 3.4, it's a preview feature. The compiler is sound for common patterns, but edge cases exist. Before you roll vapor mode out to production, you need to understand what's supported and what isn't.

Vapor mode handles these well:

  • Simple bindings and interpolations
  • Basic event listeners
  • Computed properties and watchers
  • Conditional rendering with v-if
  • List rendering with v-for (without complex keys)
  • Static classes and attributes

Areas where vapor mode has limitations or requires careful testing:

  • Slots with complex scoping
  • Dynamic components or async components
  • Provide/inject patterns across vapor and standard components
  • Custom directives (some work, others don't)
  • Ref forwarding to child components

We had an incident in a CRM application where a modal component used scoped slots for custom content. We attempted to convert it to vapor mode, but the slot scoping compiled incorrectly, breaking the component's dynamic content. The fix was to keep that component in standard mode and only convert the simpler modal wrapper. This taught us to always test thoroughly, even on seemingly straightforward migrations.

The stability concern isn't a blocker—it's a reminder to be methodical. Run your existing test suite after converting a component to vapor mode. Test edge cases manually. Monitor performance in staging before production deployment. Check the official Vue and Nuxt release notes regularly because vapor mode's feature set is expanding.

Common mistakes

Assuming vapor mode always improves performance: Vapor mode helps with bundle size and CPU usage, but only for components that are simple and update frequently. A component that renders once and never changes sees minimal benefit. Conversely, a complex component with many reactive dependencies may see no gain or even a slight regression due to compiler complexity.

Converting large, feature-rich components: Don't start by converting your most complex components. Convert the simple ones first. A 200-line component with nested slots, custom directives, and provide/inject is a poor candidate for early vapor mode migration.

Ignoring slot and directive compatibility: Vapor mode doesn't support all slot patterns or all custom directives. Attempting to migrate a component that relies on scoped slots or a third-party directive can break it silently or cause confusing errors. Always check compatibility first.

Mixing vapor and standard components without testing: If your parent component is vapor mode and child is standard (or vice versa), props and events still flow correctly, but there can be edge cases around reactivity. Test these boundaries thoroughly.

Not benchmarking before and after: Don't assume a migration helps. Use your browser's DevTools Performance tab, Lighthouse, or a RUM solution to measure real impact. We've seen cases where vapor mode migration reduced bundle size by 5% but had no measurable impact on Core Web Vitals because the component wasn't a bottleneck.

Key takeaways

  • Vapor mode compiles templates to direct DOM manipulation, eliminating the virtual DOM for simpler components.
  • Bundle size reduction and faster execution are real benefits, especially for frequently-updating or high-volume components like lists and tickers.
  • Opt-in per component means gradual adoption and the ability to measure impact before full migration.
  • Stability is still improving; start with simple, leaf components and expand cautiously.
  • Always benchmark, test edge cases, and verify slot/directive compatibility before production deployment.
  • Vapor mode is a tool for specific scenarios, not a universal replacement for standard Vue components.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles