Rendering Performance: Virtual Lists, v-memo, shallowRef and Memory Leak Detection
Chapter 30: Rendering Performance — Virtual Lists, v-memo, shallowRef, and Memory Leak Investigation
A list with 10,000 DOM nodes can scroll smoothly at 60 fps on a typical computer — but the same data volume rendered with a plain
v-forwill freeze the browser for over a second. Virtual lists are not a luxury; they are survival technology for large dataset scenarios.
The central question of this chapter: Where do performance bottlenecks typically occur in Vue applications, and how do you use the right tools — virtual lists, v-memo, shallowRef — with the right diagnostic workflow to address them?
After reading this chapter you will understand:
- The internal algorithm of virtual lists (fixed-height O(1) vs. dynamic-height binary search), and when virtual lists are mandatory
- When to use
v-memovs.shallowRef/markRaw, and how they reduce unnecessary reactivity overhead - The 5 Vue-specific memory leak scenarios, and the complete workflow for locating leaks using Chrome Memory Heap Snapshots
Level 1 · What You Need to Know (1–3 Years Experience)
1.1 The Correct Performance Diagnosis Workflow
You must find the bottleneck before optimizing. Premature optimization is the root of all evil — optimizing the wrong place is worse than not optimizing at all.
Step 1: Use Vue DevTools Performance Panel
Vue DevTools' Performance panel records and analyzes component render times:
- Open Vue DevTools in the browser
- Switch to the "Performance" tab
- Click "Start recording"
- Perform the operation that feels slow (scrolling, clicking, loading data)
- Click "Stop recording"
- Identify which components have the longest render times
Judgment criteria:
- Single component render > 16ms → frame rate drops below 60fps, user perceives lag
- Single component render > 100ms → user experience severely degraded
Step 2: Chrome Performance Panel (lower-level analysis)
Performance → Record → perform the action → Stop
Focus on Long Tasks in the Main thread:
- Long Tasks (tasks > 50ms) are the direct source of performance problems
- Click on a specific task to see whether it's JS execution,
style calculation, or layout causing the delay
1.2 The Performance Limits of v-for with Large Lists
DOM nodes are expensive. Each DOM node consumes approximately 1–2 KB of memory internally in the browser and contains dozens of properties and methods. Rendering 1,000 complex list items means:
- Creating 1,000+ DOM nodes
- The browser must calculate styles, layout, and painting for 1,000+ nodes
- Every data update requires Vue to diff 1,000+ VNodes
Measured data (reference values):
| List item count | Plain v-for initial render time | Virtual list render time |
|---|---|---|
| 100 items | ~20ms | ~20ms (no difference) |
| 1,000 items | ~200ms | ~20ms |
| 10,000 items | ~2000ms (2 second freeze) | ~20ms |
Conclusion: Consider virtual lists when the list exceeds 500 items; virtual lists are mandatory beyond 1,000 items.
1.3 The Core Principle of Virtual Lists
The central idea of a virtual list: only render what the user can actually see (the visible area).
┌─────────────────────────────┐ ← Scroll container (fixed height, overflow: auto)
│ │
│ ┌───────────────────────┐ │ ← Spacer element that stretches the container
│ │ Ghost element │ │ height = total items × item height
│ │ (invisible) │ │
│ │ height = 10000 × 50px │ │
│ │ │ │
│ │ item 50 │ │ ← Visible area starts
│ │ item 51 │ │ absolutely positioned, top = 50 × 50px
│ │ item 52 │ │
│ │ item 53 │ │
│ │ item 54 │ │ ← Visible area ends
│ │ │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
Only items 50–54 (roughly 10–20 items) are actually rendered as DOM nodes. The remaining 9,980 items exist only as array data — they don't occupy DOM.
1.4 Using vue-virtual-scroller
The most mature Vue 3 virtual list library is vue-virtual-scroller:
npm install vue-virtual-scroller@next
Fixed-height list (most common scenario):
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const items = ref(Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: `Details for item ${i}`
})));
</script>
<template>
<RecycleScroller
class="scroller"
:items="items"
:item-size="72" <!-- fixed height of each item (pixels) -->
key-field="id" <!-- field used to identify each data item -->
v-slot="{ item }"
>
<div class="item">
<h3>{{ item.name }}</h3>
<p>{{ item.description }}</p>
</div>
</RecycleScroller>
</template>
<style>
.scroller {
height: 600px; /* must set a fixed height */
}
.item {
height: 72px;
display: flex;
align-items: center;
}
</style>
Dynamic-height list (variable content height, like social media posts):
<template>
<DynamicScroller
:items="items"
:min-item-size="50" <!-- minimum item height -->
key-field="id"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem
:item="item"
:active="active"
:size-dependencies="[item.content]" <!-- data that affects height -->
:data-index="index"
>
<!-- content can be any height -->
<PostCard :post="item" />
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
1.5 v-memo: Precision Control Over Sub-tree Re-rendering
v-memo is a directive introduced in Vue 3.2, similar to React's React.memo. It accepts a dependency array and only re-renders the marked element or component when values in the array change:
<template>
<!-- Only re-renders when item.id or item.selected changes -->
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<!-- If neither item.id nor item.selected changed,
the entire sub-tree's VNode is reused — diff and patch are skipped -->
<div class="item-header">{{ item.name }}</div>
<div class="item-body">{{ item.description }}</div>
<div class="item-footer">
<span :class="{ selected: item.selected }">{{ item.selected ? 'Selected' : 'Unselected' }}</span>
</div>
</div>
</template>
When v-memo is useful:
- Large v-for lists: 100+ items with complex content per item, but in most updates only a few items' selected state changes
- Frequently-updating data with mostly-unchanged rows: e.g. a real-time data table that refreshes once per second, but 95% of rows are unchanged
1.6 shallowRef and markRaw: Reducing Reactivity Overhead
Vue 3's reactive and ref deeply proxy objects with ES6 Proxy, which can produce measurable performance overhead for large objects.
shallowRef: Only .value itself is reactive — internal properties are not tracked:
import { shallowRef, triggerRef } from 'vue';
// Use case: ECharts instance (no need for reactive tracking of internal state)
const chartInstance = shallowRef(null);
onMounted(() => {
chartInstance.value = echarts.init(chartEl.value);
});
// When you need to manually notify dependents (internal data changed but .value reference didn't):
function updateChart(data) {
chartInstance.value.setOption(data); // directly manipulate the instance
triggerRef(chartInstance); // manually notify if needed
}
markRaw: Permanently marks an object so it will never be converted to a reactive proxy:
import { reactive, markRaw } from 'vue';
// Use case: Three.js Scene (large 3D object tree — shouldn't be proxied)
const state = reactive({
scene: markRaw(new THREE.Scene()), // markRaw prevents proxying
renderer: markRaw(new THREE.WebGLRenderer()),
userData: { name: 'My 3D App' } // plain object — will be proxied
});
// When state.scene is accessed, the Proxy sees the markRaw flag and skips nested proxying
// Avoids the overhead of recursively proxying thousands of Three.js internal objects
Performance comparison:
const bigData = Array.from({ length: 10000 }, (_, i) => ({
id: i, value: Math.random(), nested: { x: i, y: i * 2 }
}));
console.time('reactive deep');
const r1 = reactive(bigData); // recursively proxies all 10,000 objects
console.timeEnd('reactive deep'); // ~50ms
console.time('shallowRef');
const r2 = shallowRef(bigData); // only proxies .value itself
console.timeEnd('shallowRef'); // ~0.1ms
Level 2 · How It Actually Works (3–5 Years Experience)
2.1 Virtual List Algorithm Implementation
Understanding the internal algorithm makes it possible to debug and optimize correctly when problems arise.
Fixed-height virtual list core calculation (O(1) complexity):
class FixedHeightVirtualList {
constructor({ itemHeight, containerHeight, items }) {
this.itemHeight = itemHeight;
this.containerHeight = containerHeight;
this.items = items;
// Number of items visible in the container
this.visibleCount = Math.ceil(containerHeight / itemHeight);
// Buffer (extra items rendered to prevent white flash during scroll)
this.bufferCount = Math.floor(this.visibleCount / 2);
}
// Calculate which items to render based on scroll position
getVisibleRange(scrollTop) {
// First visible item index — O(1) direct calculation
const startIndex = Math.floor(scrollTop / this.itemHeight);
const bufferedStart = Math.max(0, startIndex - this.bufferCount);
const bufferedEnd = Math.min(
this.items.length - 1,
startIndex + this.visibleCount + this.bufferCount
);
return { start: bufferedStart, end: bufferedEnd };
}
// Get offset for absolute positioning — O(1)
getOffsetForIndex(index) {
return index * this.itemHeight;
}
get totalHeight() {
return this.items.length * this.itemHeight;
}
}
Dynamic-height virtual list (binary search, O(log n) complexity):
When item heights vary, multiplication can't calculate offsets directly. A cumulative height array must be maintained, and binary search used for positioning:
class DynamicHeightVirtualList {
constructor({ items }) {
this.items = items;
this.heightCache = new Array(items.length).fill(50); // default estimate: 50px
this.positionCache = this.buildPositionCache();
}
buildPositionCache() {
const positions = new Array(this.items.length + 1);
positions[0] = 0;
for (let i = 0; i < this.items.length; i++) {
positions[i + 1] = positions[i] + this.heightCache[i];
}
return positions;
}
// Binary search: find start index for scrollTop — O(log n)
findStartIndex(scrollTop) {
let low = 0, high = this.items.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midPos = this.positionCache[mid];
if (midPos === scrollTop) return mid;
if (midPos < scrollTop) low = mid + 1;
else high = mid - 1;
}
return Math.max(0, low - 1);
}
// Update height cache after item renders with actual height
updateHeight(index, actualHeight) {
if (this.heightCache[index] !== actualHeight) {
const diff = actualHeight - this.heightCache[index];
this.heightCache[index] = actualHeight;
for (let i = index + 1; i <= this.items.length; i++) {
this.positionCache[i] += diff;
}
}
}
}
2.2 Vue 3's 5 Memory Leak Scenarios
Memory leaks are more severe in SPAs than in multi-page apps, because users may stay on a page for hours. Memory that GC cannot reclaim keeps accumulating.
Scenario 1: Global event listeners not cleaned up
<script setup>
import { onMounted, onUnmounted } from 'vue';
// Wrong: no cleanup
onMounted(() => {
window.addEventListener('resize', handleResize);
// This listener holds a reference to the component
// After the component unmounts, GC cannot reclaim it
});
// No onUnmounted! → Each mount adds a new listener that's never cleaned up
// Correct:
onMounted(() => {
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize); // Required!
});
// Or use useEventListener composable (auto-cleanup)
import { useEventListener } from '@vueuse/core';
useEventListener(window, 'resize', handleResize); // auto-removed on unmount
</script>
Scenario 2: setInterval/setTimeout not cleared
<script setup>
let intervalId = null;
onMounted(() => {
intervalId = setInterval(() => {
fetchLatestData(); // this closure holds a reference to component data
}, 5000);
});
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
});
</script>
Scenario 3: Pinia store holds a reference to an unmounted component's ref
const useUIStore = defineStore('ui', {
state: () => ({
// Wrong: storing a direct reference to a component's DOM ref in a global store
activeElementRef: null,
}),
});
// In the component:
onUnmounted(() => {
// Must clear the reference in the store!
uiStore.activeElementRef = null;
});
Scenario 4: provide data grows unboundedly while holding large objects
// Root-level parent component
const bigDataset = reactive({
users: [], // might have tens of thousands of user records
cache: new Map() // might cache many computed results
});
provide('dataset', bigDataset);
// Problem: even after child components using dataset are unmounted,
// the provided data stays alive (because the parent is alive),
// and the data grows over time without bound
Scenario 5: Unstable v-for keys cause frequent component destruction and re-creation
<!-- Wrong: using random numbers or timestamps as keys -->
<template>
<div v-for="item in items" :key="Math.random()">
<!-- Every re-render destroys and re-creates all items -->
<!-- If components have setInterval, new timers are created repeatedly -->
<HeavyComponent :data="item" />
</div>
</template>
<!-- Correct: use stable IDs -->
<template>
<div v-for="item in items" :key="item.id">
<HeavyComponent :data="item" />
</div>
</template>
2.3 Memory Leak Investigation with Chrome Memory Panel
Complete memory leak investigation workflow:
Step 1: Establish a baseline
│ Open Chrome DevTools → Memory panel
│ Click "Take Heap Snapshot" (baseline snapshot)
│ Note current heap size (e.g. 15 MB)
│
Step 2: Trigger the suspicious operation
│ Repeatedly perform the operation that may cause a leak:
│ - Open and close a dialog 10 times
│ - Navigate between routes 10 times
│ - Mount and unmount a component 10 times
│
Step 3: Force GC
│ Click the garbage can icon in the top-left of DevTools
│ Wait for GC to complete
│
Step 4: Take a second snapshot
│ Click "Take Heap Snapshot" again
│ If memory has grown significantly (e.g. 15 MB → 25 MB), there's a leak
│
Step 5: Compare snapshots
│ Select the second snapshot in the list
│ Change "Summary" to "Comparison" (compare with first snapshot)
│ Look at ΔSize (memory added) and ΔCount (objects added)
│
Step 6: Locate the leaking objects
│ Sort by ΔSize descending
│ Expand the type with the largest growth (often "Detached DOM tree" or "Array")
│ View the object's Retainers (who is holding it, preventing GC)
│ Trace back to the specific Vue component or store
Level 3 · Design Documents and Source Code (Senior Developers)
3.1 v-memo Source Code Implementation
v-memo compiles to a call to withMemo:
// packages/runtime-core/src/helpers/withMemo.ts
export function withMemo(
memo: any[], // dependency array
render: () => VNode[], // render function
cache: any[], // cache from component instance
index: number // cache slot index
) {
const cached = cache[index] as VNode | undefined;
if (cached && isMemoSame(cached, memo)) {
// Dependencies unchanged: return cached VNode directly
return cached;
}
// Dependencies changed: re-render
const ret = render();
// Store the dependency array on the VNode for next comparison
(ret as any).memo = memo.slice();
return (cache[index] = ret as VNode);
}
function isMemoSame(cached: VNode, memo: any[]) {
const prev: any[] = (cached as any).memo;
if (prev.length !== memo.length) return false;
for (let i = 0; i < memo.length; i++) {
if (!Object.is(prev[i], memo[i])) {
// Uses Object.is for strict equality (NaN-safe)
return false;
}
}
return true;
}
Compiled output of v-memo:
<!-- Source -->
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
{{ item.name }}
</div>
// Compiled (simplified)
renderList(list, (item, index) => {
return withMemo(
[item.selected], // memo array
() => createElementVNode('div', { key: item.id }, item.name),
_cache, // component instance cache
index // cache slot keyed by index
);
})
3.2 Internal Difference Between shallowRef and ref
// packages/reactivity/src/ref.ts
// ref: deep reactivity
export function ref<T>(value?: T) {
return createRef(value, false); // shallow = false
}
// shallowRef: shallow reactivity
export function shallowRef<T>(value?: T) {
return createRef(value, true); // shallow = true
}
function createRef(rawValue: unknown, shallow: boolean) {
const ref = {
__v_isRef: true,
__v_isShallow: shallow,
dep: undefined,
_rawValue: rawValue,
_value: shallow ? rawValue : toReactive(rawValue),
// ↑
// deep ref calls reactive() on the value here
// shallowRef doesn't — stores the raw value directly
get value() {
trackRefValue(this);
return this._value;
},
set value(newVal) {
const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal);
newVal = useDirectValue ? newVal : toRaw(newVal);
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
this._value = useDirectValue ? newVal : toReactive(newVal);
triggerRefValue(this, newVal);
}
}
};
return ref;
}
The critical difference:
ref(bigObject)→ callsreactive(bigObject)internally → recursively proxies all propertiesshallowRef(bigObject)→ stores the raw object directly, no internal property proxying
3.3 Vue 3 Reactivity Overhead Analysis
Vue 3's Proxy intercepts every property access to track dependencies:
// Simplified Proxy tracking logic
function createReactiveProxy(target) {
return new Proxy(target, {
get(target, key, receiver) {
// Runs on EVERY property access:
track(target, TrackOpTypes.GET, key); // record dependency
const result = Reflect.get(target, key, receiver);
if (isObject(result)) {
return reactive(result); // recursively proxy on first access (cost of deep proxying)
}
return result;
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, TriggerOpTypes.SET, key, value); // notify dependents
return result;
}
});
}
This is why ECharts instances, Three.js Scenes, and other third-party library objects should use markRaw or shallowRef instead of reactive.
Level 4 · Edge Cases and Traps (For Everyone)
Trap 1: Virtual List Scroll Position Resets When Data Updates
Broken scenario: The virtual list's scroll position resets to the top after data updates:
<!-- Wrong: changing key causes RecycleScroller to remount when items.length changes -->
<RecycleScroller :items="items" :key="items.length" :item-size="50" />
Correct approach: Keep the RecycleScroller's key stable; only update the items prop:
<!-- Correct: key is stable, only items updates -->
<RecycleScroller :items="items" :item-size="50" key-field="id" />
To programmatically reset scroll position:
<script setup>
const scrollerRef = ref(null);
function scrollToTop() {
scrollerRef.value.scrollToItem(0);
}
</script>
<template>
<RecycleScroller ref="scrollerRef" :items="items" :item-size="50" />
</template>
Trap 2: v-memo Combined With v-if Causes Unexpected Stale Content
Broken scenario:
<template>
<div v-for="item in items" :key="item.id" v-memo="[item.visible]">
<!-- Problem: when item.visible changes false → true, v-memo detects
the change and re-renders the sub-tree. But item.data inside v-if
is NOT in the memo array. If item.data changed while item.visible
was false, the updated data is never shown. -->
<div v-if="item.visible">{{ item.data }}</div>
</div>
</template>
Rule: The v-memo dependency array must include ALL reactive data that affects the sub-tree's rendering.
<!-- Correct: memo includes all render-influencing data -->
<div v-memo="[item.visible, item.data]">
<div v-if="item.visible">{{ item.data }}</div>
</div>
Trap 3: markRaw Stops Working After Reassigning to a Reactive Property
Broken scenario:
const state = reactive({
chart: null
});
// Initial assignment correctly uses markRaw
state.chart = markRaw(echarts.init(el));
// Later, some other code reassigns state.chart without markRaw
state.chart = echarts.init(el); // Missing markRaw!
// Vue will try to deeply proxy the ECharts instance — may cause performance issues or errors
Correct approach: Always apply markRaw every time you assign:
function initChart(el) {
state.chart = markRaw(echarts.init(el)); // markRaw every time
}
function reinitChart(el) {
state.chart?.dispose();
state.chart = markRaw(echarts.init(el)); // markRaw every time
}
Trap 4: Timer Closures That Reference Component Data
Complex leak scenario:
<script setup>
const data = ref([]);
let intervalId;
onMounted(() => {
// This arrow function is a closure that holds a reference to `data`
intervalId = setInterval(() => {
data.value = fetchData(); // data is a reactive ref captured by this closure
}, 1000);
});
// If the component unmounts during route navigation but there's no onUnmounted cleanup:
// - The setInterval callback still runs every second
// - data.value is still being updated
// - The ref object (containing the reactive tracker) cannot be GC'd because the interval holds it
// Memory leak: every second, a data fetch and reactive update occur for an unmounted component
</script>
Correct code (with complete cleanup):
<script setup>
const data = ref([]);
let intervalId = null;
onMounted(() => {
intervalId = setInterval(async () => {
data.value = await fetchData();
}, 1000);
});
onUnmounted(() => {
clearInterval(intervalId);
intervalId = null;
data.value = []; // release data reference (optional, but helps GC)
});
</script>
Trap 5: Image Lazy Loading with IntersectionObserver Conflicts with Virtual List DOM Reuse
Problem scenario: Using IntersectionObserver for image lazy loading inside a virtual list:
// Image lazy loading directive
app.directive('lazy', {
mounted(el, binding) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
el.src = binding.value;
observer.disconnect();
}
});
observer.observe(el);
// Problem: each img element creates an IntersectionObserver instance
// In a virtual list, DOM nodes are recycled (reused)
// The old observer isn't cleaned up, and the new image src won't be set correctly
}
});
Correct approach: Properly clean up in the directive's unmounted and updated hooks:
app.directive('lazy', {
mounted(el, binding) {
el._lazyObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
el.src = binding.value;
el._lazyObserver.disconnect();
el._lazyObserver = null;
}
});
el._lazyObserver.observe(el);
},
updated(el, binding) {
// When virtual list recycles a DOM node, binding.value changes
if (binding.value !== binding.oldValue) {
if (el._lazyObserver) {
el._lazyObserver.disconnect();
}
el.src = '';
el._lazyObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
el.src = binding.value;
el._lazyObserver.disconnect();
el._lazyObserver = null;
}
});
el._lazyObserver.observe(el);
}
},
unmounted(el) {
if (el._lazyObserver) {
el._lazyObserver.disconnect();
el._lazyObserver = null;
}
}
});
Chapter Summary
-
Diagnose before optimizing: Use Vue DevTools Performance panel to find the slowest-rendering components first, then Chrome Performance panel to analyze Long Tasks, and only then apply targeted optimizations. Skipping diagnostics typically means optimizing the wrong thing.
-
Consider virtual lists at 500+ items: Use
RecycleScrollerwith O(1) index calculation for fixed-height lists; useDynamicScrollerwith binary search for variable-height lists. Always handle DOM reuse scenarios correctly in virtual lists (such as image lazy loading). -
Large objects must use shallowRef or markRaw: ECharts instances, Three.js Scenes, and other third-party library objects should not be deeply proxied by Vue.
shallowRefonly tracks.valuereplacement;markRawentirely excludes reactive tracking. Choose based on whether reactive updates are needed. -
The 5 Vue-specific memory leak categories: Global event listeners, timers, store references to DOM, unbounded provide data, and unstable keys causing frequent re-creation. Every resource acquired in
onMountedmust have corresponding cleanup inonUnmounted. -
Heap Snapshot comparison is the gold standard for leak detection: Baseline snapshot → perform operation 10 times → force GC → second snapshot → compare ΔSize. Look for Detached DOM trees and the object types with the highest ΔCount, then trace the Retainer chain to specific code lines.