Vue Reactivity & Memory Management
Vue 3’s reactivity system is built on Proxy-based tracking: every ref, reactive object, computed, and watch produces a ReactiveEffect that subscribes to the dependencies it reads. Those effects are cheap individually, but each one holds a live reference to the closure it wraps — and therefore to the component props, store slices, and DOM nodes that closure captures. When an effect is not stopped at the right moment, it becomes a GC root anchor that keeps an entire component graph alive after the component has visually disappeared. This guide, part of the Framework-Specific Memory Optimization section, explains where Vue retains reactive memory and gives you a repeatable DevTools workflow to release it. For the narrower watcher and computed cases, jump to preventing memory leaks in Vue watchers and computed; for store-level retention, see Pinia and Vuex store memory retention.
Conceptual Grounding
Every reactive read in Vue 3 runs inside an active ReactiveEffect. When the effect’s function executes, Vue records which Dep objects it touched — one Dep per reactive property — and adds the effect to each Dep’s subscriber set. This creates a two-way link: the effect points at its dependencies, and each dependency points back at the effect. That back-reference is the crux of Vue memory retention. As long as a Dep is reachable (because the reactive object or Pinia store that owns it is reachable), every subscribed effect is reachable too, and so is everything each effect’s closure captured.
Vue manages the forward direction of this graph with effect scopes. When a component mounts, Vue creates an implicit EffectScope and makes it active for the duration of setup(). Any watch, watchEffect, or computed created synchronously during setup is registered with that scope. On unmount, Vue calls the scope’s stop(), which iterates its recorded effects and removes each one from every Dep it subscribed to — severing the back-references and letting the closures, props, and detached DOM nodes become collectable.
The leak surface is everything that escapes the scope. An effect created after an await, inside a setTimeout, or in a .then() callback runs when the implicit scope is no longer active, so Vue never records it and never stops it. A computed cached on a module-level singleton lives as long as the module. A watcher pointed at a Pinia store outlives every component that reads that store. In each case the ReactiveEffect remains in the store’s Dep subscriber set, and a heap snapshot’s Comparison view shows a rising ReactiveEffect count that never returns to baseline.
It is worth being precise about the three object types you will actually see in a snapshot, because they behave differently. A ReactiveEffect is the raw wrapper around a watch or watchEffect callback and is the object that appears in a Dep’s subscriber set. A ComputedRefImpl is a computed value; it is lazy — it only subscribes to its dependencies once something reads its .value, and it caches the result until a dependency invalidates it. A Dep is the per-property dependency record that both of the above attach to. When you diagnose a leak you are really asking one question: which live Dep is still holding a subscriber that should have been removed? Because computed is lazy, an unread computed on a dead component is harmless — but the moment a template or another effect reads it, it wires itself into the graph and can pin its source. This laziness is why a leak sometimes only manifests after a specific interaction rather than on every mount.
The diagram below traces this lifecycle — from the component scope that should dispose effects, to the store Dep that keeps a stray watcher alive after unmount.
Diagnostic Workflow
Follow these steps in order. The forced-GC step is what separates a real retained effect from an object that V8 simply had not collected yet.
Step 1 — Open a clean profiling session. Launch an incognito Chrome window (no extensions) and navigate to the route before the suspected component. Open DevTools → Memory and select the Heap Snapshot radio option.
Expected output: The snapshot header reports a baseline, e.g. Heap size: 18.6 MB.
Step 2 — Capture the baseline.
Click Take snapshot and label it baseline. Do not mount the component yet.
Expected output: Filtering by ReactiveEffect shows the app’s steady-state effect count (record this number).
Step 3 — Exercise the reactive lifecycle. Mount and unmount the component or navigate into and out of the route 10 times. This magnifies any per-cycle retention so a single stray watcher is obvious against noise.
Expected output: No visible error; the route renders and tears down each cycle.
Step 4 — Force a full garbage collection.
Click the trash-can icon (“Collect garbage”) in the Memory toolbar. If you need to script it, launch Chrome with --js-flags="--expose-gc" and call window.gc() in the Console.
Expected output: Young-generation objects drop immediately; the JS heap line in the Performance monitor dips.
Step 5 — Take the comparison snapshot.
Click Take snapshot again. In the view dropdown at the top of the Objects panel select Comparison, with the baseline as the reference snapshot. In the filter bar type ReactiveEffect, then repeat for ComputedRefImpl and Dep. Sort by the Delta column descending.
Expected output: A clean teardown shows ReactiveEffect delta at or near +0. A leak shows +10 (one per cycle) with a positive retained-size delta. If the count sits at a small non-zero number that does not scale with the cycle count — say a flat +2 regardless of whether you ran 10 or 20 cycles — that is app-lived infrastructure (a Pinia store subscription, a router guard) rather than a per-mount leak, and you can safely exclude it. The tell for a genuine leak is a delta that grows linearly with the number of mount/unmount cycles.
Step 6 — Trace the retainer and fix.
Select a leaked ReactiveEffect row and read the Retainers panel at the bottom. A component-scoped leak points back through a setTimeout handler or a Promise; a store-scoped leak points back through a Pinia/Vuex Dep subscriber array. Add the missing stop(), onScopeDispose, or unwatch call, then repeat steps 1–5 until the delta is +0. For the theory of when the swept effect actually frees memory, see how mark-and-sweep garbage collection works.
Code Patterns & Signatures
Pattern 1: A watcher created after await escapes the component scope.
Use this to spot the single most common Vue leak — a reactive effect registered once the synchronous setup scope has already closed.
// --- LEAKY: watch runs after await, outside the scope ---
async function setup(props) {
const user = await fetchUser(props.id) // scope
// closes here
// This watch is NOT recorded by the
// component scope — never stopped
watch(sourceRef, () => {
render(user.profile) // captures user
})
}
// --- FIXED: capture the stop handle, dispose it ---
async function setup(props) {
const user = await fetchUser(props.id)
// watch() returns its own stop function
const stop = watch(sourceRef, () => {
render(user.profile)
})
// onScopeDispose still fires on unmount
onScopeDispose(() => stop()) // detach effect
}
Heap impact (Comparison view, ReactiveEffect filter): the leaky version adds +1 effect per mount that never clears; the fixed version returns to +0 after unmount and GC.
Pattern 2: effectScope() groups many effects behind one disposer.
Use this in a composable or service that owns several long-lived watchers, so a single call tears them all down.
import { effectScope, watch, computed } from 'vue'
// Create a detached scope you control
function createTracker(sourceRef) {
const scope = effectScope() // owns effects
const state = scope.run(() => {
// Every effect below registers with scope
const doubled = computed(() => sourceRef.value * 2)
watch(sourceRef, (v) => report(v)) // tracked
return { doubled }
})
// One call stops the computed AND the watch
return { state, dispose: () => scope.stop() }
}
const tracker = createTracker(myRef)
// On teardown, release both effects at once:
tracker.dispose() // ReactiveEffect delta → 0
Heap impact: without scope.stop(), each createTracker call leaves two ReactiveEffect objects subscribed to myRef’s Dep; calling dispose() removes both, and the retained closures over sourceRef become collectable.
Pattern 3: a manual DOM listener needs onBeforeUnmount, not reactivity.
Use this when a component attaches a native listener — Vue’s effect scope does not track addEventListener, so it leaks like any closure memory leak.
import { onBeforeUnmount } from 'vue'
function useResizeReporter(store) {
// Named handler so it can be removed by ref
const onResize = () => {
store.width = window.innerWidth // captures
} // store
window.addEventListener('resize', onResize)
// Vue does NOT auto-remove native listeners
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
})
}
Heap impact: the listener anchors store on window (a GC root). Without the onBeforeUnmount removal, every mount adds a detached component that a snapshot reports as retained; with it, the Detached node count stays flat — the same signature covered under detached DOM nodes and memory retention.
Symptom-to-Fix Reference
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
ReactiveEffect count rises per route visit |
Watcher created after await in setup |
Store watch() handle, call in onScopeDispose |
Delta returns to +0 after GC |
| Store keeps growing after components unmount | Watcher subscribed to Pinia state never stopped | Add scope.stop() in store teardown |
Store retained size flat across cycles |
| Composable leaks on every consumer | Effects created without an effectScope |
Wrap effects in effectScope().run() |
One dispose() clears all effects |
| Detached nodes climb per mount | Native listener not removed | removeEventListener in onBeforeUnmount |
Detached node count stays flat |
ComputedRefImpl retained after unmount |
computed cached on a module singleton |
Move computed into component scope | Computed collected after GC |
keep-alive heap never shrinks |
Cache holds every visited view | Set :max on <keep-alive> |
Bounded cache; heap plateaus |
| Watcher fires after component gone | { flush: 'post' } effect queued late |
Guard handler or stop before unmount | No post-unmount callbacks |
Edge Cases & Gotchas
Deferred effects escape the implicit scope
Vue only records effects created while its component scope is synchronously active. The instant you await, schedule a setTimeout, or register inside a .then(), that scope is no longer current and the effect is orphaned. The fix is to capture the stop handle that watch/watchEffect returns and invoke it from onScopeDispose, which still fires on unmount even though the effect itself was never auto-registered. This is the Vue-specific mirror of an uncleaned React effect — contrast the lifecycle handling in React component leaks.
Pinia setup stores need their own scope discipline
A Pinia store defined with the setup syntax runs its body inside a store-level effect scope that lives as long as the app. Any watch you place directly in the store body therefore never stops on its own — it is meant to be app-lived. If that watcher captures a component-supplied callback or a large payload, it pins that data for the app’s lifetime. Keep store watchers pure over store state only, and push component-specific reactions into the component. The store-retention mechanics are covered in depth under Pinia and Vuex store memory retention.
keep-alive caches are unbounded by default
<keep-alive> deliberately keeps unmounted component instances — and every reactive effect they own — in memory so they can be restored instantly. Without a :max prop the cache grows with each distinct view visited, and a heap snapshot shows the retained instances never falling. Always set an explicit bound, e.g. <keep-alive :max="10">, and remember that cached components fire onDeactivated, not onUnmounted, so any manual listeners must be torn down in onDeactivated to avoid duplication on reactivation.
computed on a singleton outlives every component
A computed ref assigned to a module-level constant or a globally shared object registers a ComputedRefImpl that is reachable for the module’s lifetime. Because the computed subscribes to its source Dep, it also keeps that source alive. When you compare snapshots this appears as a ComputedRefImpl that never drops. Move such computed values inside the component’s setup() so Vue’s scope disposes them, or wrap the singleton in an effectScope() you can stop deliberately.
watchEffect re-subscribes on every run
watchEffect re-collects its dependencies each time it runs, which means a conditional branch that reads a large reactive object only on certain runs will subscribe to that object’s Dep intermittently. This makes the effect’s retained size fluctuate between snapshots and can hide a leak: a snapshot taken on a run where the branch was not taken shows a small effect, while the branch that reads the heavy object pins it. Prefer an explicit watch(source, cb) with a stable source when you need predictable retention, and reserve watchEffect for effects whose dependency set genuinely varies. If you must use watchEffect, stop it deterministically rather than relying on it settling to a small footprint.
flush: 'post' effects can fire after teardown
By default watchers flush before the component updates, but { flush: 'post' } queues the callback to run after the DOM has patched. During a rapid unmount — for example a route change that resolves mid-frame — a post-flush callback can fire against a component that is already tearing down, touching refs whose backing nodes are detached. This does not always leak, but it produces the confusing signature of a callback running “after” the component is gone. Guard the handler with a mounted flag, or stop the watcher explicitly in onBeforeUnmount so the queued job is cancelled before it runs.
Reactivity holds references at object granularity
Reading any property of a reactive object inside an effect subscribes the effect to that specific key’s Dep, but the closure still captures the whole proxied object. As with V8 closure contexts, referencing one field of a large reactive store pins the entire object graph in the effect’s captured scope. Destructure with toRefs and pass only the individual refs a long-lived effect needs, rather than the whole store object. Confirm the reduction in a heap snapshot by watching the effect’s retained size fall.
Frequently Asked Questions
Why does memory grow every time I navigate between Vue routes?
Route changes unmount a component, but any watcher, watchEffect, event listener, or interval created outside Vue’s synchronous setup() effect scope is not stopped automatically. Each visit registers a fresh effect that keeps capturing props and store slices, so the ReactiveEffect count in a Comparison heap snapshot climbs by one set per navigation until the tab is closed. Reproduce it by navigating in and out ten times, forcing GC, and filtering ReactiveEffect in DevTools → Memory → Comparison view; a delta of +10 confirms one leaked effect per visit.
Does Vue automatically stop watchers created in setup()?
Yes, but only for effects created synchronously inside setup() or <script setup>. Vue attaches them to the component’s implicit effect scope and stops them on unmount. Any watcher created inside an async callback, a Promise .then, a setTimeout, or after an await runs outside that scope and must be stopped manually with the handle watch() returns — typically invoked from onScopeDispose so it still fires when the component tears down.
When should I use effectScope() manually?
Use effectScope() when you create reactive effects outside a component — in a composable that owns a long-lived pool of watchers, in a Pinia store defined with the setup syntax, or in a shared singleton service. effectScope() collects every effect created inside its run() callback so a single scope.stop() disposes them all, which avoids tracking individual stop handles and prevents the partial-cleanup bugs that arise when one watcher in a group is forgotten.
How is Vue reactivity cleanup different from React’s useEffect?
React ties cleanup to the function returned from useEffect, which runs on dependency change and unmount. Vue instead tracks effects through an effect scope bound to the component instance, so synchronous watchers self-dispose without an explicit return. The trade-off is that Vue’s automatic disposal only covers effects registered during synchronous setup; anything deferred escapes the scope and leaks exactly as an uncleaned React effect would. The diagnostic signature — a rising effect count in a Comparison snapshot — is identical across both frameworks.
Related
- Framework-Specific Memory Optimization — the parent guide covering leak patterns across React, Vue, and Angular
- Preventing Memory Leaks in Vue Watchers and Computed — the focused child guide on
watch,watchEffect, and computed caching - Pinia and Vuex Store Memory Retention — how store
Depsubscriber sets keep effects alive across components - React Component Memory Leaks and Lifecycle Cleanup — the sibling area contrasting Vue’s effect scope with React’s
useEffectcleanup - Mastering the Chrome DevTools Memory Tab — the main section on snapshot capture, Comparison view, and retainer tracing