Performance and Vapor Mode
Measure an update with performance.now() and nextTick(), keep row props stable so one click re-renders two rows instead of 2,000, skip work with v-memo, and see what Vapor Mode in Vue 3.6 (a release candidate) changes and what it leaves out.
What you'll learn
When you finish, you can do these.- 1Time an update with performance.now() and nextTick()
- 2Keep row props stable so one click re-renders two rows
- 3Skip unchanged rows with v-memo, without stale prices
- 4Opt a component into Vapor Mode and check its limits
Picture an online shop with 2,000 products on one page, where clicking a product highlights it. On a laptop the highlight feels instant. On an older phone it lags behind the tap. A slow Vue page usually has one cause: a small change makes Vue re-render far more components than it should, running each one's template again to see what it shows. So you measure, change one thing, and measure again.
The Code tab holds that page: App.vue lists the products, each drawn by a small component in Row.vue, under a readout that counts the rows each click re-rendered and times it. You will see why a click re-renders two rows and not 2,000, skip more work with the v-memo directive, then meet Vapor Mode, a new way to compile components. You need one idea from the lesson Reading Options API code: the Options API, Vue's older way to write a component as one object with sections such as methods. Vapor Mode leaves it out.
Measure before you change anything
Before you speed anything up, you need a number: how long one update takes. measure() in App.vue reads the clock, runs the change it is given, waits with nextTick() until Vue has updated the page, and reads the clock again.
async function measure(change: () => void) {
stats.rowUpdates = 0
const start = performance.now()
change()
await nextTick()
const ms = (performance.now() - start).toFixed(1)
report.value = `${stats.rowUpdates} rows re-rendered in ${ms} ms`
}
performance.now() is the browser's precise clock. The difference covers Vue's work, re-rendering and patching the page (changing the elements that differ), but not the layout and painting the browser does afterwards.
report is the readout under the two buttons. Each row is a Row component from Row.vue. Its onUpdated() hook, which Vue calls after every re-render, adds one to stats.rowUpdates, the count the readout prints.
In a real project, the browser's Performance panel times the whole page, the performance timeline in Vue DevTools times each component, and app.config.performance = true in main.ts adds Vue's marks to that panel in development.
Keep row props stable
What each row receives decides how many rows a click re-renders. A child component re-renders when one of its props, the values its parent passes in, changes. App.vue gives each Row two props: item, the product, and active, true only for the selected product.
<Row
v-for="item in visible"
:key="item.id"
:item="item"
:active="item.id === activeId"
@select="measure(() => (activeId = item.id))"
/>
activeId holds the id of the selected product. Click a row: the readout says 2 rows re-rendered, the old selection and the new one. For the other 1,998, active was false and is still false, so Vue skipped them.
Click Raise prices, the button that adds one euro to every price, and the readout says 2000 rows, which is right: every price changed.
Skipped rows still cost something. On every click the parent builds 2,000 virtual nodes, plain objects that describe the rows, and compares each with the last one. v-memo="[item.id === activeId]" on the Row tells Vue to reuse the row's previous virtual node unless a value in the array has changed, so it neither builds nor compares the other 1,998.
Use v-memo on long v-for lists, after measuring, and list everything in the row that can change: the array above misses the price, the second mistake in What goes wrong.
Two related tools: v-once renders an element once and never updates it. For too many elements, a virtual list (useVirtualList in VueUse) renders only the rows in view.
Vapor Mode, still a release candidate
Vapor Mode goes further and drops the virtual DOM, Vue's tree of virtual nodes: a Vapor component compiles its template into code that updates the exact element that reads each piece of state. It is the big change in Vue 3.6, a release candidate in October 2026: a near-final test version, not yet stable.
Virtual DOMState changes, the render function builds a new tree of virtual nodes, Vue compares it with the old tree, then patches the elements that differ.
Vapor (Vue 3.6 RC)State changes, and the compiled code updates the one element that reads it. No tree is built and nothing is compared.
You turn Vapor on one component at a time, with the word vapor on a block: <script setup vapor>, <script vapor> or <template vapor>. Check what the component uses first, because a Vapor component supports less:
- The Composition API only, the style of
<script setup>: no Options API and nov-memo. - No
app.config.globalProperties, and less of Vue's low-level API:getCurrentInstance()returnsnull, and a template ref to a component has no$el,$props,$attrs,$slotsor$refs. - Custom directives, your own
v-attributes, take a different shape. - Events are delegated: Vapor listens once, on the document, as events rise to it. If an element around the component stops one on the way with
event.stopPropagation(), Vapor handlers inside never run.
A component written as a render function, with h() or JSX instead of a template, stays a virtual-DOM component, and an app that mixes both kinds installs vaporInteropPlugin.
Until 3.6 is stable, try Vapor on a component with no child components and a fast-changing list, and time it before and after. The Code tab runs Vue 3.5, so Vapor stays text here.
What goes wrong
The first mistake moves the comparison into the row: Row.vue takes activeId as a prop and compares it with its own item.id, so App.vue passes every row the same value.
<Row
v-for="item in visible"
:key="item.id"
:item="item"
:active-id="activeId"
@select="measure(() => (activeId = item.id))"
/>
Each click now reports 2000 rows re-rendered and takes several times as long, although only two rows change colour. Every row's prop changed, and Vue cannot know that 1,998 rows compute the same false from it. Pass each row the boolean it needs:
<Row
v-for="item in visible"
:key="item.id"
:item="item"
:active="item.id === activeId"
@select="measure(() => (activeId = item.id))"
/>
A click reports 2 rows re-rendered again.
The second mistake is a v-memo array that lists the selection but not the price:
<Row
v-for="item in visible"
:key="item.id"
v-memo="[item.id === activeId]"
:item="item"
:active="item.id === activeId"
@select="measure(() => (activeId = item.id))"
/>
Click Raise prices: the readout says 0 rows re-rendered and every price stays the same. Vue did what the memo said: nothing in the array changed. Add the price:
<Row
v-for="item in visible"
:key="item.id"
v-memo="[item.id === activeId, item.price]"
:item="item"
:active="item.id === activeId"
@select="measure(() => (activeId = item.id))"
/>
Raise prices updates every row again.
What to remember
- Measure first:
performance.now()around a change andawait nextTick(), the DevTools performance timeline, andapp.config.performancein development. - A child re-renders when a prop changes. Pass each row what it needs,
:active="item.id === activeId", not the value every row compares. v-memoreuses a row's virtual node until a value in its array changes. List everything the row shows, or it goes stale.- Vapor Mode in Vue 3.6, a release candidate, updates elements directly. Opt in per component, as with
<script setup vapor>, and check the limits first: no Options API, nov-memo, delegated events.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Pass
activeIdto every row. InRow.vue, change theactiveprop toactiveId: number, and bind:aria-pressed="item.id === activeId"and:class="{ active: item.id === activeId }". InApp.vue, replace:activewith:active-id="activeId". Click a few rows: the readout says 2000 rows re-rendered, and the time is several times higher. Change both files back. - Add
v-memo="[item.id === activeId]"to the Row inApp.vue. Click rows: still 2 rows re-rendered, and the time drops. Click Raise prices: 0 rows re-rendered and the prices stay put. Additem.priceto the array and they update. - Make Cheapest first sort the list: change
visibletocomputed(() => cheapestFirst.value ? items.value.sort((a, b) => a.price - b.price) : items.value). Click Cheapest first twice: the list stays sorted after you turn it off, because.sort()reordereditemsitself. Sort a copy,[...items.value].sort(…), and turning it off brings back Product 1, 2 and 3 at the top.