A few weeks ago, a junior developer on my team wired an API call directly into a Vue watcher. Everything seemed fine...until the browser tab sat open for an afternoon.
Network usage crept up, the page slowed, and a memory leak started to show.
Instead of swooping in with a one-line fix, I let them trace the problem themselves. It became a teaching moment and a reminder of how deceptively dangerous watchers can be.

A Vue component on your app might be leaking memory and you have no idea, or worse...it might have happened on production and your clients are seeing the graphic above.
Why Watchers Leak
Watchers feel magical: “when X changes, do Y.”
But that magic can quietly:
-
Keep old references alive if you create closures or attach listeners inside the callback.
-
Spawn multiple API requests that never resolve or get cancelled.
-
Re-register the same side effect every time the component re-renders.
A single un-cleaned interval, event listener, or promise chain can add up to megabytes of retained memory. Let's see some examples;
1. Closures that Keep Old References Alive
Problem
A watcher captures a big object inside its callback. Each change creates a new closure that still references the previous object.
<script setup lang="ts">
import { ref, watch } from 'vue'
const hugeObject = ref({ items: Array(10_000).fill('data') })
watch(hugeObject, (val) => {
// Every change creates a closure holding the *old* val
someAsyncProcess(() => console.log(val.items.length))
})
</script>
Because the callback passed to someAsyncProcess
never gets cleared, the old val
stays in memory.
Cleanup
Return a stop handle and cancel or remove the work in onUnmounted
:
import { ref, watch, onUnmounted } from 'vue'
const hugeObject = ref({ items: Array(10_000).fill('data') })
const stop = watch(hugeObject, (val, _, onCleanup) => {
const handler = () => console.log(val.items.length)
someAsyncProcess(handler)
// Vue gives us onCleanup to run when watcher invalidates
onCleanup(() => cancelAsyncProcess(handler))
})
onUnmounted(stop)
2. Multiple API Requests that Never Resolve
Problem
A value changes quickly; each change triggers a fetch without cancelling the previous one. This is the most common symptom for memory leak issues in Vue watchers.
const query = ref('')
watch(query, async (q) => {
await fetch(`/api/search?q=${q}`)
})
If the user types fast, many requests stay alive, holding memory and possibly updating out of order.
Cleanup
Use an AbortController or an external library that supports cancellation(for example, Axios' CancelToken):
import { ref, watch } from 'vue'
const query = ref('')
let currentController: AbortController | null = null
watch(query, async (q, _, onCleanup) => {
// Cancel any in-flight request
currentController?.abort()
currentController = new AbortController()
try {
await fetch(`/api/search?q=${q}`, { signal: currentController.signal })
} catch (e) {
if (e.name !== 'AbortError') throw e
}
onCleanup(() => currentController?.abort())
})
3. Event Listeners Registered on Every Change
Problem
Attaching listeners inside a watcher without removing them leads to stacked handlers.
const enableResizeTracking = ref(false)
watch(enableResizeTracking, (enabled) => {
if (enabled) {
window.addEventListener('resize', reportSize)
}
})
Toggling enableResizeTracking
repeatedly adds more and more listeners.
Cleanup
import { ref, watch } from 'vue'
const enableResizeTracking = ref(false)
watch(enableResizeTracking, (enabled, _, onCleanup) => {
if (enabled) {
window.addEventListener('resize', reportSize)
onCleanup(() => window.removeEventListener('resize', reportSize))
}
})
onCleanup
runs whenever the watcher re-triggers or when it’s stopped, guaranteeing only one active listener.
Clues You’re Leaking Memory
-
Growing heap in Chrome DevTools even when nothing is happening.
-
Lingering network activity after navigating away.
-
Performance degradation over hours or after several route changes.
I encourage teammates to reproduce these issues: open Performance tab, take heap snapshots, watch retained objects. Seeing the leak live is far more powerful than a code review comment.
General Pattern to Teach Your Team
If a watcher starts something (listener, request, timer), it must also stop it.
Encourage developers to:
-
Always use the third argument
(_, __, onCleanup)
in a watcher. -
Stop the watcher with the handle returned by
watch
when the component unmounts if it’s long-lived. -
Think of watchers as resources with a lifecycle, not just callbacks.
These practical examples both demonstrate the leak and show the idiomatic Vue 3 cleanup strategy.
Bonus: onWatcherCleanup
in Vue 3.5+
Vue 3.5 introduced a small but welcome enhancement to watcher cleanup.
onWatcherCleanup
lets you register a teardown callback inside a watcher or watchEffect, without relying on the older positional onCleanup
argument.
import { watch, onWatcherCleanup } from 'vue'
const userId = ref(0)
watch(userId, (id) => {
const controller = new AbortController()
fetch(`/api/user/${id}`, { signal: controller.signal })
// Register cleanup *inside* the watcher
onWatcherCleanup(() => controller.abort())
})
You must call it synchronously within the watcher’s callback or effect function, before any await
or other async boundary.
Use onWatcherCleanup
whenever you start something inside a watcher that needs explicit teardown like aborting a fetch, clearing an interval, or removing an event listener.
It’s the same leak-prevention power as onCleanup
, just with a cleaner and more discoverable API.
Takeaway
Watchers are powerful, but unmanaged side effects turn them into silent memory leaks.
Whether you’re leading a team or writing solo, treat every watcher as a resource you must clean up.
Your users and your Chrome task manager will thank you.
Join to participate in the discussion