Vue.js 95 🟢 Memory Leak Profiling, Detached DOM Nodes, and Unsubscribed Effects
Vue’s reactivity system is designed to clean up after itself. When a component unmounts, its effect scope stops, its watchers are disposed, and its reactive dependencies are released. The leaks that remain are almost never Vue’s fault. They are caused by code that creates a resource outside the component’s scope—an event listener on window, a setInterval timer, a third-party library instance—and never releases it. The garbage collector cannot reclaim a component’s memory if a closure from a global resource still references it. The profiler’s job is to find the retaining path: the chain of references from a GC root to the leaked object.
This chapter covers the three leak sources that account for most Vue memory problems, the heap snapshot comparison that identifies them, the retainers panel that explains them, and the effectScope boundary that prevents them in composables that run outside a component.
Key point: A detached DOM node is a node that has been removed from the document tree but is still referenced by JavaScript. The reference is what prevents collection. The retaining path in a heap snapshot shows exactly which variable, closure, or listener is holding the reference, and that path is the fix.
Why memory leaks persist despite Vue’s cleanup
The scope boundary problem. Vue’s automatic cleanup applies to effects created inside a component’s setup scope. A watch or watchEffect created synchronously during setup is captured by the component’s effect scope and stopped on unmount. An effect created after an await in a nested callback—a .then() handler, a setTimeout callback—may not be captured if the active instance is not restored. The effect runs forever, holding a reference to the component’s reactive state.
The external resource problem. A setInterval callback closes over the component’s ref. The timer is not a Vue effect; it is a browser API. Vue does not know it exists, so it cannot stop it. As long as the interval is active, the callback function holds the closure, the closure holds the ref, and the ref holds the component instance. The component’s DOM nodes remain in memory even after they are removed from the document.
The third-party library problem. A Choices.js select, a Mapbox map, or a CodeMirror editor creates its own DOM nodes and event listeners. Vue’s v-if removes the component’s root element, but the library’s additional nodes and listeners are not part of Vue’s virtual DOM. They remain attached to the document or referenced by the library’s internal state until the library’s destroy() method is called.
a. Profiling with Chrome DevTools Heap Snapshots
The heap snapshot is the primary tool for finding leaks. The workflow is a comparison between two states: a baseline snapshot after the application has warmed up and garbage has been collected, and a second snapshot after the suspected leaking interaction has been repeated.
// In the browser console, force a GC before taking a snapshot
// Chrome DevTools → Memory → Heap snapshot → Collect garbage
The comparison view shows the delta between the two snapshots. Objects with a positive delta are accumulating. The Detached filter in the class search box shows DOM nodes that are no longer in the document but are still in memory.
The Retainers panel is the most important part of the investigation. Clicking a leaked object shows the chain of references that are keeping it alive. A typical retaining path for a leaked Vue component looks like this:
Window
→ EventListener("resize")
→ onResize closure
→ component instance
→ detached DOM subtree
The path identifies the exact resource that needs cleanup. In this example, the resize listener was added to window but never removed. The fix is to remove it in onBeforeUnmount or onUnmounted.
b. Detached DOM Nodes and the Key Issue
A detached DOM node is not always a leak. It becomes a leak when JavaScript holds a reference to it after it has been removed from the document. Vue’s v-if removes the component’s DOM, but if a closure or a listener still references the component instance, the DOM nodes remain in memory.
A specific case that produces detached nodes in Vue 3 involves <video> elements. When two videos are swapped with v-if and the elements do not have distinct keys, Vue attempts to patch in place across the branch switch. Chromium’s media internals can retain references to the swapped element, producing detached <video> nodes that are not collected. Assigning distinct :key values forces a full unmount and remount, which allows the browser to release the old element.
A similar issue was reported with cached static VNodes in Vue 3.5.13, where detached <h1> elements appeared in heap snapshots after a v-if toggle. The workaround was to set hoistStatic: false in the compiler options, which prevents the static VNode from being cached and reused across mounts.
These are edge cases, but they illustrate the principle: a detached node is a symptom. The cause is always a reference that outlives the node’s removal from the document.
c. Unsubscribed Effects and the EffectScope Boundary
Vue’s watch and watchEffect return a stop handle. When the effect is created inside a component’s setup scope, Vue calls the stop handle automatically on unmount. When the effect is created outside that scope—in a composable that runs after an await, in a Pinia store, or in a utility function—the stop handle is not called automatically.
The effectScope API is the mechanism for grouping effects and stopping them together. A composable that creates effects outside a component can wrap them in a scope and expose a dispose method.
import { effectScope, watchEffect } from 'vue'
export function usePolling(fetcher) {
const scope = effectScope()
const data = ref(null)
scope.run(() => {
watchEffect(() => {
// effect is captured by the scope
})
})
return {
data,
dispose: () => scope.stop(),
}
}
The onScopeDispose hook registers a callback that runs when the current scope stops. It works in both component and non-component scopes, which makes it the correct choice for composables that may be used in either context.
The most common leak pattern in composables is an effect created after an await in a nested callback. The <script setup> compiler restores the active instance after a top-level await, but it does not restore it inside a .then() callback or a setTimeout callback. An effect created there is orphaned: it runs, it tracks dependencies, and nothing ever stops it.
// LEAK: watchEffect created in a nested callback
fetch('/api').then(() => {
watchEffect(() => console.log(data.value)) // orphaned
})
// FIX: create the effect synchronously, or capture the stop handle
const stop = watchEffect(() => { /* ... */ })
onScopeDispose(stop)
Complete Example Session
// ============================================
// PART 1: THE LEAK — GLOBAL EVENT LISTENER
// ============================================
// Component adds a keydown listener but never removes it
onMounted(() => {
window.addEventListener('keydown', handleKey) // leaked
})
// Heap snapshot retaining path:
// Window → EventListener("keydown") → handleKey → component instance
// ============================================
// PART 2: THE FIX — REMOVE IN onBeforeUnmount
// ============================================
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKey)
})
// ============================================
// PART 3: THE LEAK — UNCLEARED TIMER
// ============================================
let timer
onMounted(() => {
timer = setInterval(() => { count.value++ }, 1000)
})
// Component unmounts, timer keeps running, closure holds ref
// ============================================
// PART 4: THE FIX — CLEAR TIMER
// ============================================
onBeforeUnmount(() => {
clearInterval(timer)
})
// ============================================
// PART 5: THE LEAK — ORPHANED WATCHEFFECT
// ============================================
async function setup() {
const data = await fetchData()
// No active scope here; effect is orphaned
watchEffect(() => console.log(data.value))
}
// ============================================
// PART 6: THE FIX — SYNCHRONOUS EFFECT
// ============================================
function setup() {
const data = ref(null)
// Created while scope is active
watchEffect(() => { if (data.value) console.log(data.value) })
fetchData().then(result => { data.value = result })
}
// ============================================
// PART 7: THE FIX — MANUAL STOP HANDLE
// ============================================
const stop = watchEffect(() => { /* ... */ })
onScopeDispose(stop)
// ============================================
// PART 8: THIRD-PARTY LIBRARY CLEANUP
// ============================================
onMounted(() => {
chart = new Chart(canvas, config)
})
onBeforeUnmount(() => {
chart.destroy() // library-specific cleanup
})
The eight parts trace the three most common leak sources—event listeners, timers, and orphaned effects—and the corresponding fixes.
Quick Reference
Leak Sources and Fixes
| Source | Fix |
|---|---|
window.addEventListener | removeEventListener in onBeforeUnmount |
setInterval / setTimeout | clearInterval / clearTimeout |
| Third-party instance | Call the library’s destroy() or dispose() |
Orphaned watchEffect | Create synchronously or capture stop handle |
| Global component reference | Remove the reference in cleanup |
Profiling Tools
| Tool | Purpose |
|---|---|
| Heap snapshot comparison | Find accumulating objects |
Detached filter | Find detached DOM nodes |
| Retainers panel | Find the retaining path |
| Allocation timeline | Find where objects were allocated |
EffectScope API
| Function | Purpose |
|---|---|
effectScope() | Create a scope |
scope.run(fn) | Run fn; capture effects created inside |
scope.stop() | Stop all captured effects |
onScopeDispose(fn) | Register cleanup on scope stop |
Best Practices
✅ Do This:
// Remove event listeners in onBeforeUnmount
onBeforeUnmount(() => window.removeEventListener('resize', handler)) // ✅
// Clear timers in onBeforeUnmount
onBeforeUnmount(() => clearInterval(timer)) // ✅
// Create effects synchronously in setup
watchEffect(() => { /* ... */ }) // ✅
// Use onScopeDispose for composable cleanup
onScopeDispose(() => ws.close()) // ✅
❌ Don’t Do This:
// Create watchEffect after await in a nested callback
fetch().then(() => watchEffect(() => {})) // orphaned // ❌
// Forget third-party library destroy
chart = new Chart(...) // no destroy() in cleanup // ❌
// Assume v-if cleans up global resources
// v-if only removes DOM; it does not remove listeners or timers // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Memory grows on route navigation | Component listeners not removed | onBeforeUnmount cleanup |
| Detached DOM nodes persist | Closure references component | Find retaining path, remove reference |
| Effect runs after unmount | Created outside scope | onScopeDispose or synchronous creation |
| Third-party library leaks | No destroy() call | Call library cleanup in onBeforeUnmount |
| Ref guard stuck after remount | Cleanup sets flag, setup does not reset | Set flag in setup body |
Real-World Examples
1. Remove Event Listener
onBeforeUnmount(() => window.removeEventListener('resize', handler))
2. Clear Timer
onBeforeUnmount(() => clearInterval(timer))
3. EffectScope for Composable
const scope = effectScope()
scope.run(() => watchEffect(() => {}))
scope.stop()
4. onScopeDispose
onScopeDispose(() => ws.close())
5. Synchronous Effect
watchEffect(() => { if (data.value) console.log(data.value) })
6. Stop Handle
const stop = watchEffect(() => {})
onScopeDispose(stop)
7. Third-Party Destroy
onBeforeUnmount(() => chart.destroy())
8. Heap Snapshot Comparison
Baseline → interact → repeat → snapshot → compare
9. Detached Filter
Memory → Heap snapshot → filter "Detached"
10. Retainers Panel
Click object → Retainers → find the retaining path
Visual
Leak Sources
┌──────────────────────────────────────────────────────────────┐
│ COMPONENT MOUNTS │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ onMounted: ││
│ │ window.addEventListener → not removed ││
│ │ setInterval → not cleared ││
│ │ new Chart() → not destroyed ││
│ └─────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ COMPONENT UNMOUNTS │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Vue stops its effects ││
│ │ Vue removes its DOM ││
│ │ External resources remain ││
│ └─────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ GARBAGE COLLECTOR RUNS │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ Component instance still referenced ││
│ │ by the listener/timer/library ││
│ │ → memory not reclaimed ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Vue cleans up what it creates. │
│ You clean up what you create. │
│ │
└──────────────────────────────────────────────────────────────┘
Retaining Path
┌──────────────────────────────────────────────────────────────┐
│ RETAINERS PANEL │
│ │
│ Window │
│ → EventListener("resize") │
│ → onResize closure │
│ → component instance │
│ → detached DOM subtree │
│ │
│ The bottom of the chain is the leaked object. │
│ The top of the chain is the GC root. │
│ The fix is to break the chain at the listener. │
│ │
│ window.removeEventListener('resize', onResize) │
│ │
└──────────────────────────────────────────────────────────────┘
EffectScope Boundary
┌──────────────────────────────────────────────────────────────┐
│ COMPONENT SCOPE MANUAL SCOPE │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ setup() { │ │ const scope = │ │
│ │ watchEffect() │ │ effectScope() │ │
│ │ } │ │ │ │
│ │ │ │ scope.run(() => │ │
│ │ Stopped on │ │ watchEffect() │ │
│ │ unmount │ │ ) │ │
│ └─────────────────┘ │ │ │
│ │ scope.stop() │ │
│ │ on demand │ │
│ └─────────────────┘ │
│ │
│ Both scopes stop their effects. │
│ The difference is who calls stop(). │
│ │
└──────────────────────────────────────────────────────────────┘
Detached DOM Node
┌──────────────────────────────────────────────────────────────┐
│ v-if="show" │
│ │ │
│ ▼ │
│ show = true → <div> mounted │
│ show = false → <div> removed from document │
│ │
│ IF a closure still references the <div>: │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ <div> is detached but not collected ││
│ │ Memory → Heap snapshot → filter "Detached" ││
│ │ → click the node → Retainers ││
│ │ → find the reference that keeps it alive ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Remove the reference, and the node is collected. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Heap snapshot | Compare before and after |
Detached filter | Find detached DOM nodes |
| Retainers panel | Find the retaining path |
| Event listeners | Remove in onBeforeUnmount |
| Timers | Clear in onBeforeUnmount |
| Orphaned effects | Use onScopeDispose or create synchronously |
| Third-party libraries | Call destroy() in cleanup |
effectScope | Group effects for collective stop |
Key takeaways:
- Vue cleans up its own effects; it does not clean up external resources. A
watchorwatchEffectcreated inside the component scope is stopped automatically. AsetIntervaltimer, awindowevent listener, or a third-party library instance is outside Vue’s scope and must be cleaned up explicitly. - The heap snapshot comparison identifies what is accumulating. The
Retainerspanel identifies why. The retaining path from a GC root to the leaked object shows the exact variable, closure, or listener that is holding the reference. That path is the fix. - Detached DOM nodes are a symptom, not a cause. The node is detached because it was removed from the document. It is retained because something still references it. The fix is to remove the reference, not to try to force the node to be collected.
- Effects created after
awaitin nested callbacks are orphaned. The<script setup>compiler restores the active instance after a top-levelawait, but not inside a.then()callback. The effect runs without a scope to stop it. Create effects synchronously, or capture the stop handle and call it inonScopeDispose. - Third-party libraries require explicit cleanup. A Choices.js select, a Mapbox map, or a Chart.js chart creates its own DOM and listeners. Vue’s
v-ifremoves the component’s root element, but the library’s internal state is not part of Vue’s virtual DOM. The library’sdestroy()ordispose()method must be called inonBeforeUnmount.
Remember: Memory leaks in Vue applications are almost always caused by code that lives longer than the component. The component unmounts, Vue stops its effects, and the garbage collector is ready to reclaim the memory. But a global listener, a running timer, or a third-party instance still holds a reference, and that reference is enough to keep the entire component tree alive. The profiler’s job is to find the retaining path; the fix is to release the resource that created it. The pattern is consistent: whatever was created in onMounted must be destroyed in onBeforeUnmount, and whatever was created outside the component scope must be stopped by the code that created it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!