Vue 3 disposes the reactive effects it creates. A watch or a watchEffect declared in is bound to the component instance and torn down the moment that component unmounts — no cleanup code required. So the memory leaks that reach production mostly don't live in the reactivity system. They live in the gap between what Vue owns and what a developer hands to something that outlives the component.
A user opens the reports view, goes back to the list, and opens it again. Each visit starts a setInterval polling every five seconds; each exit leaves it running. Nothing ever stops them, so the pollers accumulate across the day — every one still firing, every one still holding the response it fetched last, and the tab's footprint climbing from morning onward. By afternoon the filter box has started to stutter. A second failure class is louder. On a memory-constrained tab (a mid-range Android, an embedded webview, a TV browser) the page doesn't get slow. The browser kills it.
Both failures trace to the same root, and it isn't Vue. A 2026 static-analysis pass across 500 public repositories (the StackInsight study) convicts the unglamorous resources. It is self-published, its detectors adapted from the author's own commercial scanner (Code Evolution Lab, which the page also pitches), and it admits that formal precision and recall were never measured. Of the 15,750 leak sites it flagged in Vue repositories, the Vue-specific "missing watch stop handle" pattern accounts for roughly a quarter, though that detector flags uncaptured stop handles — including synchronous setup() watchers that Vue disposes anyway. Nearly everything else is a resource Vue never owned rather than anything its reactivity system created. The reactivity system Vue developers are drilled to fear is the smaller share of the problem.
This is a field guide to where Vue 3 leaks actually come from, and the one rule that prevents all of them. One thing is out of scope. Server-side leaks (Nuxt SSR contexts, request-scoped store state that never gets garbage-collected between requests) are a genuinely different problem with a different shape; Vue's SSR guide covers the mechanism as cross-request state pollution, and Nuxt's state-management docs indict the module-scope ref as the specific trap. Start with those. Confirming that a tab is leaking at all, rather than just using memory, is a skill of its own; that comes near the end.
The mental model worth carrying is one sentence: Vue disposes the effects it owns, and owns nothing else.
Every component's setup() runs inside an effect scope, an internal container that collects the reactive effects created during synchronous execution. The official docs insist on it: watchers "declared synchronously inside setup() or are bound to the owner component instance, and will be automatically stopped when the owner component is unmounted. In most cases, you don't need to worry about stopping the watcher yourself" (per the Vue watchers guide). A watch that fires on every store mutation, a watchEffect that re-runs on every keystroke — both vanish cleanly on unmount, because Vue was holding the handle the whole time.
A computed reaches the same place by a different route. Since 3.5 it isn't registered on the scope at all; it drops its dependencies once it loses every subscriber, and the maintainers deny it needs stopping at all: a post-3.5 computed is, in their words, "self-disposing" (per vuejs/core#11886). Either way, nothing is left for you to stop.
So the reactivity system is not the threat. The threat is everything Vue never saw you create. Three categories cover almost all of it: Manual browser APIs — setInterval, addEventListener, requestAnimationFrame, IntersectionObserver, WebSocket. Vue doesn't wrap these. It doesn't know they exist. Third-party library instances — a chart, a map, a rich-text editor. Each holds its own canvases, listeners, and buffers. References handed to something longer-lived — a module-scoped array, a global event bus, a Pinia store that keeps pushing component data and never lets go.
The skeptic dismisses the whole exercise by paragraph three: isn't this just "clean up after yourself," the same discipline every framework and vanilla page has always demanded? Partly, yes — the principle is universal. What's specific to Vue 3 is the part it handles for free (the owned effects, disposed without a line of cleanup code) and the seams it gives you for the rest: onUnmounted, onScopeDispose, effectScope, onWatcherCleanup. The principle is old. The tools are new, and most of this article is about using them.
The StackInsight study ranked all 55,864 of its flagged leak sites by category. These are the top five of nine, counted across the full React/Vue/Angular corpus; the shape of the ranking is the whole argument:
| Category | Share of leak sites | | --- | --- | | Missing timer cleanup | 43.9% | | Missing event listener removal | 19.0% | | Missing subscription cleanup | 13.9% | | Missing effect cleanup | 9.3% | | Missing watch stop handle | 7.1% |
One row needs unpacking before the shape reads correctly. Missing watch stop handle is a Vue-only detector, so its 7.1% is measured against a corpus that is roughly seventy per cent React and Angular; against Vue's own 15,750 findings, the same pattern is about a quarter. The study concedes the skew plainly: the sample was "weighted toward React".
This is a single study, not a law of nature, but the direction is hard to argue with. The top three rows, the ones Vue's auto-disposal does nothing for, are more than three-quarters of the corpus. Inside Vue's own findings, three of every four flagged sites are something other than the watcher pattern. The ranking quietly mocks the priorities most leak articles encode. A developer who memorizes the entire effectScope API and still writes setInterval without clearInterval has optimized the quarter and shipped the rest. The tooling asymmetry matches: eslint-plugin-vue ships vue/no-watch-after-await in its essential preset, which reports a watch registered after an await, and ships nothing at all for a setInterval that never gets cleared. Closing that gap is a house rule, not a plugin install: @eslint-react's web-api-no-leaked-interval does check that a setInterval is paired with a clearInterval, but reports only inside useEffect callbacks and stays silent in onMounted. What a Vue team can enforce is a no-restricted-syntax selector catching the shape that is unfixable rather than merely unfixed: a setInterval whose return value is discarded, leaving no id for any clearInterval to take. That bans one call; it never proves a teardown, because a selector cannot tie a clearInterval to the id a particular setInterval returned.
So the rest of this is organized by what Vue can't see, roughly in order of how often it bites.
These share one fix: whatever you start in onMounted, stop in onUnmounted. The interesting part is the ways the stop quietly fails to happen.
Start with listeners, because the failure has a sharp edge most people hit once. Watch the handler reference across the two calls.
The avoid version is unfixable, not just unfixed. removeEventListener matches by reference, and its cleanup call removes nothing: the second arrow is a different function from the first, however identical the two look. The window outlives the component, so the listener (and the layout closure behind it) stays registered for the life of the tab. Every remount adds another, and each one still fires. After a dozen visits a single resize event runs layout a dozen times, eleven of them on behalf of components that no longer exist.
Observers and sockets are the same story with a different verb. An IntersectionObserver watching a sentinel for infinite scroll keeps its target (and the component scope around it) alive until you disconnect().
