Preventing Memory Leaks in Vue Watchers & Computed
A watch() or watchEffect() call created outside a component’s normal setup lifecycle keeps re-running and holding its closure in memory long after the feature that created it is gone — this is one of the most common sources of retention documented across Vue reactivity and memory management, the framework-specific memory optimization area more broadly.
Symptom-to-fix diagnostic matrix
| Symptom | Root Cause | Immediate Action |
|---|---|---|
| Watcher callback fires after component unmounts | Watcher created after await, outside effect scope |
Store the returned stop handle; call it in onUnmounted |
| Pinia store size grows on every route change | Store-level watch() never disposed with the store |
Call store.$dispose() or stop the watcher manually |
Heap snapshot shows growing ReactiveEffect count |
Composable creates watchers without an effectScope |
Wrap composable internals in effectScope() |
| Large object stays retained after feature unmounts | computed() closes over large data, never re-created |
Scope the computed to the component, not a module singleton |
| Global event bus handler still fires post-unmount | Handler registered in a watcher with no matching off() |
Pair every on() with an off() in the same scope’s teardown |
Root Cause
Vue’s reactivity system ties automatic cleanup to something called the active effect scope. When setup() runs, Vue pushes a scope onto an internal stack; every watch(), watchEffect(), and computed() created synchronously during that call is registered against the current scope. On unmount, Vue walks that scope’s effect list and stops each one — no manual code required. This is why the vast majority of watchers inside <script setup> never need explicit disposal, and it’s the mechanism behind the broader reactivity model covered in Vue reactivity and memory management.
The leak appears the moment a watcher is created outside that active scope. Three situations trigger this constantly in real codebases: a watch() inside an async function body after an await (the scope stack has already been popped by the time execution resumes); a watch() or computed() created inside a Pinia or Vuex store action, since the store’s own effect scope is independent of any component’s and can outlive every component that uses it; and a watcher registered inside a module-level singleton or plugin that runs once at app bootstrap and is never revisited. In every case, the watcher’s callback closure keeps a live reference to whatever it read — component-local variables, a global event bus, DOM elements, or large fetched payloads — and that reference chain is exactly the shape of a closure memory leak: the object itself may be small, but everything it retains stays reachable indefinitely.
computed() values compound the problem because they cache their result and re-evaluate lazily on dependency change — meaning a computed created once at module scope holds its last-evaluated value and its full dependency list for the life of the process, even if every component that once read it has long since unmounted.
The diagram below contrasts the two lifecycles: a watcher created inside setup() (auto-disposed) against one created outside any component scope (never disposed unless you intervene).
Step-by-Step Fix
Step 1 — Find watchers created outside setup
Action: Search the codebase for watch( and watchEffect( calls inside async functions, Pinia/Vuex store files, plugin install() functions, and any module that runs at import time rather than component mount time.
Expected output: A list of call sites where the watcher is not a direct, synchronous child of a component’s setup() or <script setup> block.
Verification checkpoint: For each flagged call site, confirm whether Vue’s dev-mode console prints a warning like [Vue warn]: watch(): Called outside of a component's setup() — this confirms the watcher has no owning scope.
Step 2 — Capture the returned stop handle
Action: Both watch() and watchEffect() return a function that stops the watcher. Assign it to a variable instead of ignoring the return value.
Expected output: A stop reference stored on the composable’s return object, the store instance, or a module-level variable that the teardown code can reach.
Verification checkpoint: Calling typeof stop === 'function' returns true immediately after the watch() call.
Step 3 — Group related effects in an effectScope
Action: Wrap composable-internal watchers, computed properties, and effects with Vue’s effectScope() API so one call disposes all of them together instead of tracking N stop handles individually.
Expected output: A single scope object exposing scope.run() (to register effects) and scope.stop() (to dispose all of them at once).
Verification checkpoint: After calling scope.stop(), further mutations to the tracked reactive sources no longer trigger any callback registered inside scope.run().
Step 4 — Dispose in the correct teardown hook
Action: For component-adjacent composables, call the stop handle or scope.stop() inside onUnmounted. For Pinia stores, call store.$dispose() when the store itself should not outlive its consumer, or stop the specific watcher inside a store $reset/teardown action. For plugins and singletons, expose an explicit teardown() method your app calls on route change or feature unmount.
Expected output: The stop call executes exactly once per teardown, with no Cannot read properties of undefined errors from double-disposal.
Verification checkpoint: Add a temporary console.log('watcher stopped') inside the stop path and confirm it fires exactly once per unmount cycle, not zero and not multiple times.
Step 5 — Verify with a heap snapshot comparison
Action: Open DevTools → Memory → Heap Snapshot. Mount and unmount the affected component or feature ten times, forcing garbage collection between cycles with the trash-can icon in the Memory panel, then take a second snapshot and switch to the Comparison view.
Expected output: Filtering the comparison for ReactiveEffect, ComputedRefImpl, or the composable’s class name shows a #Delta at or near zero — not a count that climbs with each mount cycle.
Verification checkpoint: If the retained count still grows linearly with mount cycles, a stop handle is missing somewhere in the chain — re-check every watch() call site from Step 1.
Command & Code Reference
Capturing and using a stop handle from a watcher created inside an async composable:
// composables/useLiveOrder.js — async setup() path
import { watch, onUnmounted } from 'vue';
export async function useLiveOrder(orderId) {
// Async work runs BEFORE the watch() call below —
// by this point the component's effect scope is gone
const initial = await fetchOrder(orderId);
// watch() here has no owning scope; capture its stop fn
const stop = watch(orderId, async (id) => {
initial.value = await fetchOrder(id); // refetch on id change
});
// Manually tie disposal to the *calling* component's unmount
onUnmounted(() => stop()); // releases closure over `initial`
return { initial, stop };
}
Grouping several effects in one composable with effectScope, so a single call disposes everything:
// composables/useDashboardMetrics.js
import { effectScope, computed, watch, onUnmounted } from 'vue';
export function useDashboardMetrics(source) {
// effectScope() batches every effect created inside run()
const scope = effectScope();
const state = scope.run(() => {
// A computed that closes over a potentially large dataset
const summary = computed(() => summarize(source.value));
// A watcher logging to a global event bus
watch(summary, (val) => globalBus.emit('metrics', val));
return { summary };
});
// Single call stops the computed AND the watcher together
onUnmounted(() => scope.stop());
return state;
}
Disposing a store-level watcher that would otherwise outlive every component using it:
// stores/useSessionStore.js — Pinia store
import { defineStore } from 'pinia';
import { watch } from 'vue';
export const useSessionStore = defineStore('session', () => {
const token = ref(null);
let stopTokenWatch = null;
function startWatchingToken() {
// Store-scoped watcher — NOT tied to any component
stopTokenWatch = watch(token, (val) => {
persistToken(val); // writes to localStorage on change
});
}
function teardown() {
// Call explicitly when the feature owning this store unmounts
if (stopTokenWatch) stopTokenWatch();
}
return { token, startWatchingToken, teardown };
});
Verification & Regression Prevention
Target these thresholds after applying the fix, measured via interpreting heap snapshots for memory analysis:
| Metric | Before fix | Target after fix |
|---|---|---|
ReactiveEffect count after 10 mount cycles |
Grows by ~10 per cycle | Flat, delta ≈ 0 |
| Retained size of composable closure | Grows unbounded | Stable across cycles |
| Store instance count (SPA session) | 1 per route visit | 1 per app lifetime |
Add an ESLint rule to catch the most common mistake — a watch() call whose return value is discarded inside a composable file:
// .eslintrc.js — flag watch()/watchEffect() with no assignment
module.exports = {
rules: {
'no-unused-expressions': ['error', {
allowTernary: false,
}],
// Custom rule (via eslint-plugin-vue-scoped-css style plugin)
// or a local rule: require watch()/watchEffect() call sites
// to be assigned to a variable in composables/**/*.js
},
};
For CI, wire a lightweight Playwright or Vitest check that mounts and unmounts the component N times and asserts on performance.memory growth (Chromium runners only), similar in spirit to the disposal pattern used for unsubscribing observables to prevent Angular leaks — the underlying problem (a subscription outliving its owner) is the same shape across frameworks, only the disposal API differs. Where the retained data is genuinely optional to keep, consider whether a WeakMap, WeakRef, or FinalizationRegistry is a better fit than a strong reference held by the watcher’s closure.
Frequently Asked Questions
Do watchers created inside <script setup> need manual cleanup?
Not if they are called synchronously during setup(). Vue ties their disposal to the component’s active effect scope, and they stop automatically on unmount. Manual cleanup is only required when the watcher is created outside that scope — inside an async callback after an await, in a plugin, or in a module-level singleton.
Why does watch() inside a Pinia store leak instead of auto-disposing?
A Pinia store created outside a component (or one that outlives the component that first used it) has no attached component effect scope. Watchers registered in a store action run inside the store’s own scope, which is not torn down when a component using the store unmounts. The store — and everything its watchers close over — stays in memory for the lifetime of the app unless you call store.$dispose() or stop the watcher explicitly.
Can computed() properties leak memory too?
Yes. A computed property is itself a reactive effect with dependency tracking. If it is created in a module scope or a long-lived singleton and closes over a large array, DOM reference, or fetched dataset, that data stays reachable through the computed’s dependency graph for as long as the computed itself exists — even if no component ever reads it again.
Related
- Vue reactivity and memory management — parent guide covering the broader reactivity-to-memory relationship
- Pinia and Vuex store memory retention — store-level retention patterns beyond individual watchers
- Closure memory leaks in modern JavaScript — the general mechanism behind every unstopped watcher’s leak
- Unsubscribing observables to prevent Angular leaks — the same disposal problem in a different framework
- Framework-Specific Memory Optimization — main section for cross-framework memory patterns