Skip to content
intermediate Phase 14 · Performance Optimization and Server-Side Rendering

Profiling Render Performance

Find wasted renders with devtools flame graphs and Vite bundle analysis.

40m
0 problems
Topic Progress 0%

Recording Keystroke Lag: Reading the Devtools Flame Graph

A search box sits above a 400-row orders table, and typing feels sticky: every keystroke costs roughly 120 ms on a mid-range laptop, the console is silent, and no error fires anywhere. "Feels slow" is a hypothesis, not a measurement, so your first move is not to memoize something — it is to record what Vue is actually doing on each keystroke. Profiling is also the natural next step after the isolation skills from Mocking Modules and Stubbing Children: if you can mount one component in a harness, you can reproduce one slow interaction.

Vue 3 can emit a performance timeline that the Vue DevTools profiler reads:

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

const app = createApp(App)
app.config.performance = true
app.mount('#app')

With that flag on, Vue wraps component setup, render, and patch passes in performance.mark() and performance.measure() calls. Open the Vue DevTools Performance panel, click record, reproduce the keystroke, and stop: the recording becomes a flame graph — one bar per component update, width in milliseconds, stacked so a child update sits above the parent that triggered it. In a Vite dev setup you can skip the browser extension entirely: npm i -D vite-plugin-vue-devtools, add vueDevTools() to plugins in vite.config.js, and the same profiler lives in the dev-server overlay, with a component-tree view showing per-component render counts.

While recording, two readings decide where you look next:

Reading Where it appears A bad value means
Update duration width of a single bar one component burns more than 5 ms per keystroke
Wasted renders profiler counter per flush the component re-ran but produced no DOM change

Wasted renders are the number worth chasing. Fixing the render cost of a component that re-renders thirty times a second without changing a pixel is pointless — the win is removing the render entirely.

Read the graph top-down. The wide bar at the bottom belongs to the component that owns the state change, here the one holding the query ref. Every bar stacked above it is a child whose render effect re-ran because a tracked reactive dependency changed. When the entire table subtree lights up for two typed characters, ask the profiler's one hard question: which reactive dependency dirtied each of these components? The flame graph answers who; only reading the component's code answers why.

Typical findings from one five-minute session: OrdersTable re-renders because its parent passes :filter-fn="filterOrders" — a new arrow function on every render. StatCard re-renders because it receives :style="{ active }" — a fresh object each time. And 240 OrderRow children re-render because their parent re-rendered and their props changed by reference, even though every value is deep-equal. Those three explain the 120 ms, and none of them surface as errors.

Two caveats before you close the panel. Dev-mode timings are pessimistic: production builds skip the dev-only diff bookkeeping, so a 12 ms dev bar may be 3 ms after npm run build. Compare dev numbers against each other to find hotspots, and only trust user-facing numbers — Interaction to Next Paint, long tasks in Chrome's Performance panel — measured on a production build. Second, app.config.performance itself adds overhead to every update: leave it on while profiling, ship with it off.

The workflow that works: reproduce, record, sort by the widest bars, note the top three components, then stop profiling and read their code. Profiling tells you where the time goes; code review tells you why. Teams that profile every small change burn an afternoon on a tool that was only ever meant to point.

Why an Inline Object Prop Re-Renders 400 Rows for a Filter String

The profiler named the culprits: an inline function prop, an inline style object, and a cascade of 240 rows. This chapter explains the mechanism behind that flame graph, because in an interview you will be asked why a child re-renders when its parent does — and "because Vue is reactive" is not the answer.

Vue compares a child's props after the parent re-renders with Object.is per prop, once — a same-value reference check, never a deep comparison. Deep-comparing every prop on every parent render would cost O(size of props) per update and defeat the point of fine-grained reactivity. So this template:

<OrderRow
  :order="order"
  :style="{ fontWeight: highlighted ? 700 : 400 }"
  @click="() => select(order.id)"
/>

hands the child two brand-new references on every parent render: the style object literal and the arrow function. Object.is reports "changed", the child's render effect is dirtied, and the row re-renders for a highlight toggle that should have touched one element. The values are identical; the references are not, and references are all Vue checks.

The fix is reference stability. Hoist anything recreated per render into module scope or setup():

const rowStyle = { fontWeight: 700 } // one object, shared by every render

For events the cleanest pattern is to stop passing functions down at all — put the id on the element and handle the click in the parent:

<tbody @click="onRowClick">
  <tr v-for="o in orders" :key="o.id" :data-id="o.id">...</tr>
</tbody>

One listener at the top, zero function props, and a selection change never re-renders rows because of event wiring.

A taxonomy you can recite on demand:

Prop pattern What Vue sees Render cost
:style="{ active }" inline new object every render child always re-renders
@click="() => fn(id)" inline new function every render child always re-renders
:config="computedConfig" stable until deps change re-renders only on real change
primitive :count="3" Object.is(3, 3) is true no re-render

Two tools finish the job. v-memo="[order.id, selectedId]" on the row element caches that element's subtree and reuses it unless one of the memo dependencies changed — with 240 rendered rows, selecting a row patches one subtree instead of 240. And shallowRef protects you when a large collection is replaced wholesale: a 10,000-item orders array made deeply reactive proxies every item on access, while shallowRef keeps the ref itself reactive and leaves the plain API objects untouched.

One trap while fixing these: v-memo's dependency array must list everything the cached subtree reads. Leave query out of [order.id, selectedId] and filtered rows silently keep showing stale text until something else forces a re-render. Memoization that is wrong is worse than memoization you never wrote.

The general rule to carry out of this topic: reactivity should flow from values, not identity. When a prop's reference changes but its meaning does not, you have found a wasted render — and now you can explain exactly why the flame graph lit up.

Sorting the Bundle by Byte: Finding Modules You Ship but Never Call

Rendering fixes cut keystroke latency, but they do nothing for first load: the browser still downloads one big JavaScript file before the app boots. The build output says exactly how big:

dist/assets/index-8f3a2b.js    412.19 kB │ gzip: 128.44 kB
dist/assets/index-8f3a2b.css     14.61 kB │ gzip:   4.12 kB

412 kB of JavaScript is real work on a mid-range phone — call it 400-600 ms of parse and execute before anything renders. To see which modules dominate, add rollup-plugin-visualizer:

// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [vue(), visualizer({ open: true, gzipSize: true })]
}

npm run build then opens a treemap of the entry chunk. The usual offenders, in the order you will find them: a full echarts import (about 975 kB minified, 330 kB gzipped) when three line graphs are all you chart; moment with every locale (about 300 kB minified) where dayjs covers the same calls at 2 kB; and one import _ from 'lodash-es' that drags in the whole library (about 70 kB gzipped) to call debounce once.

Each has a targeted fix, and the size math is worth internalizing:

Before After Entry chunk, gzip
import * as echarts from 'echarts' echarts/core + LineChart + TooltipComponent + CanvasRenderer 128 → 96 kB
import moment from 'moment' import dayjs from 'dayjs' 96 → 61 kB
import _ from 'lodash-es' import debounce from 'lodash-es/debounce' 61 → 54 kB

From 128 kB to 54 kB gzipped — a 2.4× reduction — without removing a feature, only unreachable code. Notice the shape of each fix: never "optimize the library", always change the import boundary so Rollup can tree-shake. The bundler drops only code it can prove is unreached, and a namespace import of everything defeats that proof.

While you are in vite.config.js, check one flag. Vue ships devtools integration code that a build can opt into:

export default {
  define: {
    __VUE_PROD_DEVTOOLS__: false // the default; never flip it on for a public build
  }
}

__VUE_PROD_DEVTOOLS__: true registers the devtools hook in your production bundle and exposes every component's state to anyone with the devtools extension. It exists for internal staging builds; a public app must ship with it off.

Two honest limits of bundle analysis. A treemap shows size, not order — it cannot tell you which module blocks first paint; that needs Chrome's coverage panel or the network waterfall. And it hides runtime cost: a 2 kB utility called inside a scroll handler can hurt users more than 40 kB of dormant code. Sort the treemap, cut the giants, then hand the remaining fat to code splitting — the subject of Async Components and Code Splitting, which attacks the same 412 kB from a different angle: not smaller modules, but later ones.

Carry one number into your next performance review: 128 → 54 kB gzipped saves roughly 90-120 ms of download-plus-parse on a 4G connection. Bundle work is measurable, and measurable wins are the ones that ship.

Quiz

1. You record typing in a search box and the profiler shows 240 OrderRow components updating per keystroke, each with ~0 ms of DOM work. What does that indicate?

Question 1 options

2. A parent re-renders and passes `:style="{ active }"` and `@click="() => select(id)"` to a child that receives no other changed data. Why does the child re-render?

Question 2 options

3. The visualizer treemap shows lodash-es contributing most of your entry chunk, but your code only calls debounce(). What is the correct first fix?

Question 3 options

4. A teammate sets __VUE_PROD_DEVTOOLS__: true in the production build 'so QA can inspect components on staging'. What is the consequence for the shipped app?

Question 4 options

Flashcards

Question

What does app.config.performance = true enable, and when should it be off?

Answer

It makes Vue emit performance.mark/measure entries around each component's setup, render, and patch, which the Vue DevTools Performance panel renders as a flame graph with per-component timings and wasted-render counts. It adds overhead to every update, so enable it while profiling and ship with it off.

Question

Why does an inline object or inline arrow-function prop cause a child to re-render every time its parent renders?

Answer

Vue compares child props with Object.is — one reference comparison per prop, never a deep compare. The inline object literal and inline arrow function are new references on every parent render, so the check reports a change and the child's render effect is dirtied even though the values are logically identical.

Question

What is a wasted render, and why is it the number the profiler leads with?

Answer

A wasted render is a render-effect run that produced byte-identical DOM — the component re-executed but nothing the user can see changed. It is pure CPU cost, so the optimization is eliminating the re-render (stable props, v-memo, shallower state), not making the render function faster.

Question

How do you see which modules dominate a Vite production bundle?

Answer

Add rollup-plugin-visualizer (or run npx vite-bundle-visualizer) and build; the plugin emits a treemap of chunk contents, best read with gzipSize enabled. Pair it with the plain build output lines (kB plus gzip per chunk) to rank modules by actual transfer cost.

Question

What does __VUE_PROD_DEVTOOLS__ control, and what is the safe setting?

Answer

It controls whether Vue's devtools integration is compiled into the production bundle. Safe setting is false (the default): true registers the global devtools hook so any visitor with the extension can inspect component state. Set it via define in vite.config.js for internal builds only.

Revision Notes

Key Takeaways

  • 1. Profiling is measurement: enable app.config.performance (or vite-plugin-vue-devtools), record the interaction, and let the flame graph name the components instead of guessing
  • 2. Wasted renders — re-runs that produce no DOM change — are the primary target; making a pointless render faster fixes nothing
  • 3. Props are compared with Object.is, so inline objects and inline arrow functions create new references every parent render and force child re-renders by identity, not by value
  • 4. A treemap from rollup-plugin-visualizer ranks modules by transfer size; the fix for a fat library is changing the import boundary so tree-shaking works, not optimizing the library
  • 5. __VUE_PROD_DEVTOOLS__ compiles Vue's devtools hook into the production bundle and must stay false for public builds — it exposes component state to anyone with the extension

Interview Tips

  • • "A page feels slow — walk me through what you do." — record with the profiler first, quote the wasted-render count, then name the unstable prop pattern; process (measure, then fix) beats naming tools
  • • "Why does a child re-render when its parent does?" — answer with the mechanism: props checked via Object.is, no deep compare, so new object/function references count as changes; finish with the hoisting and event-delegation fixes
  • • "How would you shrink a 400 kB bundle?" — visualizer treemap, then import boundaries (echarts/core, per-function lodash-es), then code splitting; numbers like 128 → 54 kB gzip make the answer concrete
  • • "What's the risk of devtools in production?" — __VUE_PROD_DEVTOOLS__: true ships the hook and exposes reactive state; mention you'd allow it only on internal staging builds

Cheat Sheet

Profiling Render Performance

Profile a slow interaction:

Step Action
1 app.config.performance = true in dev, or install vite-plugin-vue-devtools and add vueDevTools() to Vite plugins
2 Vue DevTools → Performance panel → record → reproduce → stop
3 Read the flame graph: widest bar = slowest component; stacked bars = children re-rendered by a parent update
4 Wasted renders (re-ran, no DOM change) are the primary target
5 Stop profiling, read the top 3 components' code for unstable props

Unstable-prop taxonomy:

Pattern Vue sees Fix
:style="{ active }" inline new object per render hoist to setup() / computed
@click="() => fn(id)" inline new function per render event delegation via data-id
large immutable list in ref deep proxies on every item shallowRef / markRaw
repeated subtree patching 240 row updates per selection v-memo="[item.id, selectedId]" (deps must be complete!)

Bundle side:

npm run build              # read per-chunk kB + gzip lines
npx vite-bundle-visualizer # treemap of what's inside each chunk
// vite.config.js — keep devtools out of public builds
define: { __VUE_PROD_DEVTOOLS__: false }

Numbers worth quoting: echarts full import ~330 kB gzip → echarts/core + needed pieces ~100 kB; entry chunk example 128 → 54 kB gzip (~90-120 ms saved on 4G).

Continue Learning