Chapter 30

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-for will 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:


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:

  1. Open Vue DevTools in the browser
  2. Switch to the "Performance" tab
  3. Click "Start recording"
  4. Perform the operation that feels slow (scrolling, clicking, loading data)
  5. Click "Stop recording"
  6. Identify which components have the longest render times

Judgment criteria:

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:

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:

  1. Large v-for lists: 100+ items with complex content per item, but in most updates only a few items' selected state changes
  2. 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:

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

  1. 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.

  2. Consider virtual lists at 500+ items: Use RecycleScroller with O(1) index calculation for fixed-height lists; use DynamicScroller with binary search for variable-height lists. Always handle DOM reuse scenarios correctly in virtual lists (such as image lazy loading).

  3. 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. shallowRef only tracks .value replacement; markRaw entirely excludes reactive tracking. Choose based on whether reactive updates are needed.

  4. 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 onMounted must have corresponding cleanup in onUnmounted.

  5. 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.

Rate this chapter
4.7  / 5  (3 ratings)

💬 Comments