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

Vue effect scope lifecycle: auto-disposed vs leaking watchers Two parallel timelines. The left timeline shows a component mounting, its setup() creating a watch tied to the active effect scope, and the component unmounting, which automatically stops the watcher. The right timeline shows a watcher created inside an async callback after an await, outside any active effect scope, which keeps running and retaining its closure even after the originating component unmounts, unless a stop handle or effectScope is used. Watcher inside setup() — safe Component mounts, setup() runs watch() registers on active effect scope Component unmounts Scope auto-stops watcher closure released, GC-eligible Watcher after await — leaks Async setup() calls fetch(), awaits Effect scope already popped off the stack watch() runs with no owning scope Component unmounts — watcher keeps firing, closure retained Fix: capture stop handle, or wrap in effectScope() and call scope.stop()

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.


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.