Skip to content
All articles
Vue.jsComposition APIReactivityPerformance

Vue 3 Cleans Up After Itself — Until It Can't

Vue 3 auto-disposes the reactive effects it owns, so the leaks that actually bite live in the manual timers, listeners, subscriptions, and long-lived references it never sees. A field guide to where Vue 3 memory leaks come from — and the one rule that prevents all of them.

26 min read

Vue 3 disposes the reactive effects it creates. A watch or a watchEffect declared in <script setup> 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.

Vue already cleans up the part everyone worries about

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 <script setup> 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 APIssetInterval, 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.

Where the leaks actually live

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:

CategoryShare of leak sites
Missing timer cleanup43.9%
Missing event listener removal19.0%
Missing subscription cleanup13.9%
Missing effect cleanup9.3%
Missing watch stop handle7.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.

Manual browser resources: timers, listeners, subscriptions, observers

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.

Avoid
TypeScript
onMounted(() => {
  window.addEventListener('resize', () => layout(window.innerWidth))
})

onUnmounted(() => {
  // a second arrow — a different function, so this removes nothing
  window.removeEventListener('resize', () => layout(window.innerWidth))
})
Prefer
TypeScript
function onResize() {
  layout(window.innerWidth)
}

onMounted(() => {
  window.addEventListener('resize', onResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', onResize)
})

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

Avoid
TypeScript
const observer = new IntersectionObserver(onIntersect)

onMounted(() => {
  observer.observe(sentinel.value)
})
Prefer
TypeScript
const observer = new IntersectionObserver(onIntersect)

onMounted(() => {
  observer.observe(sentinel.value)
})

onUnmounted(() => {
  observer.disconnect()
})

A WebSocket wants close(), an EventSource wants close(), a ResizeObserver wants disconnect(), a requestAnimationFrame loop wants cancelAnimationFrame. Same rule, same seam. Timers, the most common leak in the study's corpus, get the canonical treatment in the recommendation block below; the fix is clearInterval, and the trap is forgetting that the interval's callback keeps its entire closure alive between ticks.

The two watcher leaks

Watchers leak in two distinct ways, and conflating them is why the advice around them is muddled. Only one is the reactivity system's.

The first leak isn't the watcher — it's what the watcher starts. A watcher that opens a socket, registers a listener, or fires a request on every change, without tearing down the previous one, stacks resources on each run. The rule worth memorizing: if a watcher starts something (a listener, a request, a timer), it must also stop it. Vue 3.5 added onWatcherCleanup for exactly this: a teardown that runs when the watcher is invalidated and about to re-run (per the Vue docs on side effect cleanup). It fires on the way out, too. Vue registers the cleanup as the effect's onStop hook, so unmounting the component closes the last socket as well as every one superseded mid-flight.

Avoid
TypeScript
watch(roomId, (id) => {
  const socket = openSocket(id)
  socket.onMessage(handleMessage)
})
Prefer
TypeScript
import { watch, onWatcherCleanup } from 'vue'

watch(roomId, (id) => {
  const socket = openSocket(id)
  socket.onMessage(handleMessage)
  onWatcherCleanup(() => socket.close())
})

onWatcherCleanup lands in Vue 3.5+, and it has to be called during the watcher's synchronous execution — never after an await. On older versions, and past that constraint, the third callback argument (onCleanup) binds to the watcher instance instead. When the side effect is a fetch, the cleanup is an AbortController, and the same cleanup seam carries it — the deeper mechanics of racing requests get their own treatment in the piece on cancelling API requests in Vue 3, but nothing in this section waits on it. The leak angle is the simpler half: started, so stop it.

The second leak is the only one that actually lives in the reactivity system, and it's narrow. A watcher created asynchronously (after an await, inside a setTimeout, in a promise callback) is never bound to the component, so it never auto-stops. The same guide rejects any ambiguity: a watcher created in an async callback "won't be bound to the owner component and must be stopped manually to avoid memory leaks."

The honest first move is to not create watchers asynchronously at all — hoist them into synchronous setup and the auto-disposal does the work. When the timing genuinely can't be helped, capture both handles (the timer's and the one watchEffect returns) and clear them on unmount. The broken shape first:

Avoid
TypeScript
onMounted(() => {
  setTimeout(() => {
    watchEffect(() => {
      document.title = `Unread: ${unread.value}`
    })
  }, 1000)
})
Prefer
TypeScript
let timeoutId
let stop

onMounted(() => {
  timeoutId = setTimeout(() => {
    stop = watchEffect(() => {
      document.title = `Unread: ${unread.value}`
    })
  }, 1000)
})

onUnmounted(() => {
  clearTimeout(timeoutId)
  stop?.()
})

The timer is the easy half to forget: unmount before it fires and there is no watcher to stop yet, only a callback still queued to create one.

Handles and references that outlive the component

Two leak sources left, and they're the ones Chrome's heap snapshot tends to surface as "detached" nodes.

Third-party widgets allocate aggressively — a charting library holds canvases, its own resize listeners, and the dataset you handed it. Vue mounts and unmounts the wrapper component; the library instance underneath neither knows nor cares. The pattern is mechanical: store the instance, call its teardown method on unmount.

Avoid
TypeScript
let chart

onMounted(() => {
  chart = new Chart(canvas.value, config)
})
Prefer
TypeScript
let chart

onMounted(() => {
  chart = new Chart(canvas.value, config)
})

onUnmounted(() => {
  chart.destroy()
})

The subtler source is a reference you park somewhere long-lived. Module scope is the classic trap: a const at the top of a file outlives every component that imports it, so anything put into it and never removed grows for the life of the tab. The same applies to a global event bus you never off(), or a store array that accumulates component data without bound. Module scope in its plainest form is a cache nobody ever prunes:

Avoid
TypeScript
// module scope — and nothing ever removes an entry
const history = new Map()

export function useHistory(key, entry) {
  history.set(key, entry)
}
Prefer
TypeScript
import { onScopeDispose } from 'vue'

// same Map, same signature — the only change is registering the teardown
const history = new Map()

export function useHistory(key, entry) {
  history.set(key, entry)
  onScopeDispose(() => history.delete(key))
}

onScopeDispose rather than onUnmounted here, because useHistory is a composable and a composable can run in a scope that is not a component's. Swapping the Map for a WeakMap is no substitute for that teardown: WeakMap keys must be objects or non-registered symbols, so a string or numeric key throws a TypeError, and an object key only hands the entry's release to the collector's schedule instead of unmount. A weak collection fits where the key is an object whose lifetime something else already owns; here it is an identifier the caller supplies.

The same long-lived-reference trap shows up in Pinia's own $subscribe and $onAction. The safe case is the default: registered inside an active effect scope — a component's setup() included — both bind to that scope and are removed when it disposes (per the Pinia docs on state and actions). Detach them ({ detached: true } on $subscribe, true as the second argument to $onAction), or register them where no scope is active, and the returned unsubscribe function becomes yours to call. The flag is the whole difference:

Avoid
TypeScript
const cart = useCartStore()

// detached opts out of the scope cleanup — and nothing replaces it
cart.$subscribe(saveCart, { detached: true })
Prefer
TypeScript
const cart = useCartStore()

// no detach: the subscription dies with the scope that registered it
cart.$subscribe(saveCart)

A reference parked in long-lived scope has a second failure mode: a handler that closes over a template ref keeps that DOM node detached-but-alive, out of the garbage collector's reach after unmount. The reference is the leak. Clear it when the component goes; when there is no component, the next section supplies the hook.

Composables: give the effects a scope to die with

Composables complicate the mental model in one specific way. onUnmounted needs a component instance; onScopeDispose needs an effect scope. Every component setup is a scope, but not every scope is a component — a composable can just as easily run inside a Pinia store's setup or a hand-rolled effectScope(), where there is no instance of its own for onUnmounted to bind to. A composable that relies on onUnmounted for teardown is betting on a component being there to hold it.

onScopeDispose is the fix: it registers teardown on the current effect scope rather than the component instance, so it fires for any scope — component setup or otherwise. The docs frame it as "a non-component-coupled replacement of onUnmounted in reusable composition functions" (Reactivity API: Advanced). One limit rides along: the scope has to be active. Outside any scope at all — module top level, a router guard, a Vue plugin's install() — it registers nothing and warns in dev only, silently in production, exactly as onUnmounted does. Neither hook rescues that case; code that runs there owns its teardown outright.

Avoid
TypeScript
export function useSocket(url) {
  const socket = new WebSocket(url)
  onUnmounted(() => socket.close())
  return socket
}
Prefer
TypeScript
import { onScopeDispose } from 'vue'

export function useSocket(url) {
  const socket = new WebSocket(url)
  onScopeDispose(() => socket.close())
  return socket
}

And when a composable spins up several effects that should be disposed as a unit (or you need reactivity outside any component), tracking each stop handle by hand is the trap effectScope exists to remove. It's the same machinery the RFC authors lifted out of Vue's component internals precisely because, outside a component, "it's laborious to manually collect all the effects" and "easy to forget", which "might result in memory leakage" (per RFC 0041). Watch how many handles the caller has to remember in the first version.

Avoid
TypeScript
// every effect returns its own stop handle, tracked by hand
const stopSync = watch(source, sync)
const stopReport = watchEffect(report)

function teardown() {
  stopSync()
  stopReport()
}
Prefer
TypeScript
import { effectScope } from 'vue'

const scope = effectScope()

scope.run(() => {
  watch(source, sync)
  watchEffect(report)
})

function teardown() {
  // one call disposes every effect in the scope
  scope.stop()
}

The counter: "Vue cleans up, and VueUse handles the rest"

Skeptics deride all of this as solved-problem fear-mongering, and the objection comes in two parts worth taking seriously.

The first part is correct on the facts: Vue 3 does auto-dispose, so a category of older "always stop your watchers" advice is genuinely outdated. That much is granted. But auto-disposal covers a quarter of Vue's flagged sites, not the three-quarters that were never Vue's to dispose — and not even all of that quarter, since the async-registration case sits inside it and auto-disposal never reached there either. The auto-disposal that practitioners cite as the reason not to worry is doing nothing for the timers, listeners, and subscriptions on the other side of that line. This is the design verdict, and it isn't a knock on Vue: no framework can reclaim a resource it never saw allocated. The framework concedes that boundary by design. Auto-disposing owned effects is the right call. It just bounds the problem to exactly the resources Vue has a handle on, and leaves the rest to you by necessity, not oversight.

The second part is the better argument: don't hand-roll any of this; reach for VueUse, whose composables wire teardown in for you. It's the right default, not a crutch. useEventListener registers on mount, and its docs vouch for the behavior plainly: it runs "removeEventListener automatically on unmounted". useIntervalFn does the same for timers, though you have to read its source to see why: it hands pause straight to tryOnScopeDispose.

TypeScript
import { useEventListener, useIntervalFn } from '@vueuse/core'

useEventListener('resize', onResize)
useIntervalFn(refresh, 5000)

That's the listener section and the polling interval, both deleted. No onMounted, no onUnmounted, no reference-matching trap, no timer id to keep. The rule still matters, because the library is the rule applied, not a substitute for understanding it. Both composables tie their teardown to the current effect scope (the same seam this article is about), and the protection ends at the edge of what VueUse wraps. The niche charting library, the WebSocket subprotocol, the module-scoped cache: the moment a developer touches a resource no composable covers, they own teardown again, and the quiet assumption that VueUse must have handled it is how that leak ships. Reach for VueUse. Understand why it works.

Detection: confirm the leak before you chase it

Suspecting a leak and proving one are different activities, and the gap between them is where hours disappear. The recognition signal is blunt: mount a component, unmount it a dozen times, and if its instances are still in the heap after garbage collection, something is holding them. Reaching for Vue DevTools first is a dead end: its Components tab walks the mounted tree and skips instances already unmounted, so a leaked instance is precisely the one thing it cannot show. The proof lives in Chrome DevTools' Memory panel, and the workflow is mechanical (per the Chrome DevTools heap-snapshot guide): take a snapshot, navigate into the suspect component and back out several times, take a second snapshot, and switch it to Comparison view. DevTools runs a garbage collection before every snapshot, so whatever is still standing in the second one is pinned by something. Filter the class list by "Detached" to find DOM kept alive by stale references, then read the Retainers pane to see exactly what's holding on.

For teams that want deterministic checks in CI, Meta's memlab automates the snapshot-diff loop; for ad-hoc debugging, the manual panel is the first thing to reach for.

Below memlab sits a cheaper rung: a unit test asserting the teardown ran. A green test vouches only for the call, not for the reclamation. It says clearInterval fired with the id setInterval handed back, which makes it a regression guard on a leak already found rather than a way to find one.

TypeScript
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import Poller from './Poller.vue'

it('clears its interval on unmount', () => {
  const setSpy = vi.spyOn(globalThis, 'setInterval')
  const clearSpy = vi.spyOn(globalThis, 'clearInterval')

  const wrapper = mount(Poller)
  expect(setSpy).toHaveBeenCalledOnce()

  wrapper.unmount()
  expect(clearSpy).toHaveBeenCalledWith(setSpy.mock.results[0].value)
})

Poller is any component that starts an interval in onMounted. Vitest defaults to a node environment, so mount needs happy-dom or jsdom switched on. Skip vi.useFakeTimers(): it replaces the timer globals, and installing it after the spies leaves them recording nothing. The setInterval assertion is what keeps the test honest, since without it a component that starts no timer at all passes. Restore the spies as well — vi.restoreAllMocks() in an afterEach, or restoreMocks: true — because vi.spyOn on an already-spied global hands back the same spy, and a second test in the file would inherit the first one's call log.

None of that watches production. The nearest thing to a field reading is performance.measureUserAgentSpecificMemory(): Chromium-only, still a WICG draft rather than a W3C standard, and rejected with a SecurityError unless the document is cross-origin isolated under COOP and COEP. Its predecessor performance.memory is deprecated and non-standard, not a fallback. The out-of-memory kill is observable only after the fact and only out-of-band: declare a crash-reporting or default endpoint in a Reporting-Endpoints header and the browser posts a crash report once the renderer is gone, tagged reason: "oom" when the browser knows why. That mechanism is Chromium-only and defined in no published specification, which is the state of the art rather than a recommendation. Nothing inside a page that just exhausted memory survives to report on itself, which is why Sentry's maintainers deny it is detectable from inside the SDK. That endpoint is the only signal that survives the kill.

What looks like a leak but isn't

Three patterns set off false alarms worth pre-empting. <KeepAlive> deliberately retains cached component state: its growth is the feature, not a leak. What it does change is where cleanup goes: a cached component is deactivated rather than unmounted, so anything that should stop while it's hidden belongs in onDeactivated. Development-mode memory growth from HMR is not a production signal. And a Pinia store that grows isn't Pinia leaking — it's holding exactly what the application told it to hold, which means the fix is in the code that parks data there, not in the store.

What to actually do

The discipline reduces to one habit: at the moment you create a resource, ask whether Vue can see it. If it can't, you own its end.

Best practice

In general: Vue disposes the effects it owns. You own the teardown of anything you create that can outlive the component — if Vue can't see it, it can't clean it up. Reach for VueUse where it already wraps the resource; own the teardown where it doesn't. Two questions settle most rows below: what did you create, and whose scope does it die with — onUnmounted inside a component, onScopeDispose in any other effect scope a composable might run in. The exceptions are the two rows with a seam of their own: onWatcherCleanup for a side effect started inside a watcher, effectScope for several effects that must die as a unit. If <KeepAlive> caches the component, anything that should stop while it's hidden goes in onDeactivatedonUnmounted won't run until the cache drops it. Confirm the leak with a two-snapshot heap comparison before you chase it.

CaseReach for
setInterval / setTimeout / requestAnimationFrameclearInterval / clearTimeout / cancelAnimationFrame in onUnmounted — or VueUse's useIntervalFn for the timers
window / document / emitter listenerremoveEventListener (or the emitter's .off()) with the same handler reference in onUnmounted — or useEventListener
IntersectionObserver, WebSocket, EventSourcedisconnect() / close() in onUnmounted
A side effect started inside a watcheronWatcherCleanup (3.5+), or the onCleanup third argument
A watcher or effect created after an await or in a callbackcreate it synchronously if you can — otherwise capture the returned stop handle and call it, plus clearTimeout on the pending timer if a timer is what queued it
A third-party widget instancestore the handle, call its .destroy() in onUnmounted
A composable that may run outside a componentonScopeDispose — it fires for any effect scope, not just component setup
Several effects to dispose as a unit, or reactivity outside any componenteffectScope — one .stop() disposes every effect inside
Component data pushed into module / global / store scopeclear the reference in onUnmounted — or onScopeDispose if a composable is what parked it
A detached Pinia $subscribedon't detach unless something outside the component owns its end — and then that owner calls the returned unsubscribe

The one pattern to keep in muscle memory, because timers are the case people forget most — and because it is exactly what useIntervalFn wraps when you reach for VueUse instead:

Avoid
TypeScript
onMounted(() => {
  setInterval(refresh, 5000)
})
Prefer
TypeScript
let id

onMounted(() => {
  id = setInterval(refresh, 5000)
})

onUnmounted(() => clearInterval(id))

It comes down to one reflex: noticing, at the instant of creation, whether the thing you just made is something Vue is holding or something you are. Vue closed the gap it could close. The rest of the gap has your name on it, and closing it was never a matter of memorizing more APIs. It's knowing, for everything you create, whose scope it dies with.

Sources

Subscribe

Architecture decisions, performance work, and AI tooling in production — Vue, TypeScript, Node. One to four times a month.