Your Vue might be leaking memory right now, and you don’t even know it.
Here's what happens when you create a watcher that makes API calls or sets up timers. Vue runs your watcher function when the reactive data changes. But when the data changes again, Vue runs the watcher function again.
Nothing new there, but there is a problem.
Your old API request is still running.
Your old timer is still ticking:
// This creates a new timer every time searchQuery changeswatch(searchQuery, (query) => {setTimeout(() => {console.log(`Searching for: ${query}`)}, 1000)})
If you change searchQuery
five times quickly, you'll have five timers running. Change it a hundred times, and you'll have a hundred timers.
This is a common way that resource leaks and duplicated effects happen in Vue apps.
But there's a simple solution that many developers don't know about. And once you understand it, you'll never write a leaky watcher again.
onCleanup
: Your Cleanup SuperheroVue gives you onCleanup
as the third parameter in your watch
callback. It's the cleanup function that runs right before the watcher runs again.
watch(searchQuery, (query, oldQuery, onCleanup) => {const timerId = setTimeout(() => {console.log(`Searching for: ${query}`)}, 1000)// This runs before the watcher runs againonCleanup(() => {clearTimeout(timerId)})})
Now when searchQuery
changes, Vue automatically cancels the old timer before starting the new one.
No more memory leaks. No more duplicate API calls. No more performance problems.
Here's how onCleanup
works behind the scenes. Vue calls your cleanup function in two situations: when the watcher is about to run again, and when the watcher stops completely (like when a component unmounts or an effect scope is stopped).
This is the perfect time to clean up side effects.
Let's look at some common scenarios where you need cleanup.
This is a classic example of a debounced search:
import { ref, watch } from 'vue'const searchQuery = ref('')const searchResults = ref([])watch(searchQuery, (query, oldQuery, onCleanup) => {// Clear previous search results immediatelysearchResults.value = []const timerId = setTimeout(async () => {const results = await fetch(`/api/search?q=${query}`)searchResults.value = await results.json()}, 300)onCleanup(() => clearTimeout(timerId))})
The cleanup function cancels the previous search timer when the user types a new character. This prevents old searches from overwriting newer results.
Without onCleanup
, fast typing would trigger multiple API calls. The slower requests might finish last and overwrite the results from faster, more recent requests, not to mention the fact that the API would be called multiple times for the same query!
We can also use onCleanup
to manage event listeners:
import { ref, watch } from 'vue'const isModalOpen = ref(false)watch(isModalOpen, (isOpen, wasOpen, onCleanup) => {if (isOpen) {const handleEscape = (event) => {if (event.key === 'Escape') {isModalOpen.value = false}}window.addEventListener('keydown', handleEscape)onCleanup(() => {window.removeEventListener('keydown', handleEscape)})}})
This pattern adds an escape key listener when the modal opens. The onCleanup
function removes the listener when the modal closes or when the watcher runs again.
Without onCleanup
, you'd accumulate event listeners every time the modal opens. Eventually, pressing escape would trigger multiple handlers. This is a classic example of a memory leak, and is something that's extremely easy to overlook (but equally easy to fix).
Let's go back in time to Vue 3.4 and earlier and examine a little bit of the history of onCleanup
.
Vue 3.4 and earlier had a limitation. You could only register one cleanup function per watcher. If you called onCleanup
multiple times, only the last one would run.
// Vue 3.4 and earlier - only the last cleanup runswatch(data, (value, oldValue, onCleanup) => {const timer1 = setTimeout(() => {}, 1000)onCleanup(() => clearTimeout(timer1)) // This gets overwrittenconst timer2 = setTimeout(() => {}, 2000)onCleanup(() => clearTimeout(timer2)) // Only this runs})
If you wanted to clean up multiple side effects, you'd be forced to group them together in a single onCleanup
function:
watch(data, (value, oldValue, onCleanup) => {const timer1 = setTimeout(() => {}, 1000)const timer2 = setTimeout(() => {}, 2000)onCleanup(() => {clearTimeout(timer1)clearTimeout(timer2)})})
For this example it's fine, but for more complex scenarios it's a pain, and it greatly limits the ability to reuse the cleanup logic and have better code organization.
However, Vue 3.5 introduced the onWatcherCleanup
composable to solve this problem. Now you can register multiple cleanup functions independently:
import { watch, onWatcherCleanup } from 'vue'watch(userId, (id) => {// Set up a debounced saveconst saveTimer = setTimeout(() => {saveUserPreferences(id)}, 1000)onWatcherCleanup(() => clearTimeout(saveTimer))// Set up an activity trackerconst activityTimer = setInterval(() => {trackUserActivity(id)}, 30000)onWatcherCleanup(() => clearInterval(activityTimer))// Set up an API requestconst controller = new AbortController()fetchUserData(id, { signal: controller.signal })onWatcherCleanup(() => controller.abort())})
Each cleanup function manages its own side effect. When the watcher runs again, Vue calls all three cleanup functions in the order they were registered.
onWatcherCleanup
(Vue 3.5+)While onWatcherCleanup
is a powerful improvement over the single cleanup limitation of onCleanup
, it comes with a few important restrictions that you need to understand to use it effectively.
await
The most critical limitation of onWatcherCleanup
is that it only works before the first await
in an async watcher. This restriction exists because Vue needs to register cleanup functions before the async operation begins:
// ❌ This won't work - onWatcherCleanup after awaitwatch(dataId, async (newId) => {const controller = new AbortController()const response = await fetch(`/api/data/${newId}`, {signal: controller.signal,})const data = await response.json()// This cleanup registration will fail!onWatcherCleanup(() => {controller.abort()})})// ✅ This works - onWatcherCleanup before awaitwatch(dataId, async (newId) => {const controller = new AbortController()// Register cleanup before any awaitonWatcherCleanup(() => {controller.abort()})const response = await fetch(`/api/data/${newId}`, {signal: controller.signal,})const data = await response.json()})
Once an await
is encountered, the function execution is suspended, and Vue can no longer track cleanup registrations reliably. This is due to how effect scopes work in Vue (which you'll learn about more in my course, Advanced Reactivity).
If you need cleanup after await
, you must use the onCleanup
parameter instead:
watch(dataId, async (newId, _oldId, onCleanup) => {const controller = new AbortController()const response = await fetch(`/api/data/${newId}`, {signal: controller.signal,})const data = await response.json()// After await: must use onCleanup parameteronCleanup(() => controller.abort())})
Another challenge with onWatcherCleanup
is that it's not available in onMounted
or other places where you don't have a watcher context. The onCleanup
function is bound to the watcher, so it's always available and always "knows" what watcher it's attached to:
watch(data, (value) => {setupCleanup() // Works because we're in watcher context})// ❌ This won't work: outside watcher contextonWatcherCleanup(() => {// Which watcher is this for?})// ✅ This works: inside watcher contextfunction setupCleanup() {const timer = setTimeout(() => {}, 1000)onWatcherCleanup(() => clearTimeout(timer))}
Okay, now on to the good stuff.
The onWatcherCleanup
composable really shines when you extract cleanup logic into helper functions, or when you're working with third-party code that needs to register its own cleanup.
This makes your watchers cleaner and your cleanup code reusable:
import { onWatcherCleanup } from 'vue'export function useTimeout(callback, delay) {const timerId = setTimeout(callback, delay)onWatcherCleanup(() => clearTimeout(timerId))return timerId}export function useInterval(callback, delay) {const intervalId = setInterval(callback, delay)onWatcherCleanup(() => clearInterval(intervalId))return intervalId}export function useEventListener(target, event, handler) {target.addEventListener(event, handler)onWatcherCleanup(() => {target.removeEventListener(event, handler)})}export function useAbortableRequest(url, options = {}) {const controller = new AbortController()onWatcherCleanup(() => controller.abort())return fetch(url, {...options,signal: controller.signal,})}
Here, we're using the onWatcherCleanup
composable to register cleanup functions for each different side effect. But we're able to encapsulate each side effect in its own helper function, and reuse the same cleanup logic for each side effect.
This makes your watchers much cleaner:
import { watch } from 'vue'import {useTimeout,useEventListener,useAbortableRequest,} from './cleanup-helpers'watch(currentUser, (user) => {// Debounced analytics trackinguseTimeout(() => {trackUserView(user.id)}, 500)// Keyboard shortcuts for this user's roleif (user.role === 'admin') {useEventListener(window,'keydown',handleAdminShortcuts)}// Load user preferencesuseAbortableRequest(`/api/users/${user.id}/preferences`).then((response) => response.json()).then((preferences) => {applyUserPreferences(preferences)})})
The helper functions handle all the cleanup details so your watcher can focus on the business logic.
This pattern is especially powerful in libraries and composables. Third-party code can register its own cleanup without interfering with your cleanup functions, and it remains completely decoupled from your business logic.
Proper cleanup isn't just about preventing memory leaks. It's about building reliable applications that behave predictably.
Without cleanup, your app develops subtle bugs:
These bugs are hard to reproduce and harder to debug. They often only appear under specific timing conditions or after the user has interacted with your app for a while.
But with proper cleanup, your watchers become bulletproof. They clean up after themselves automatically. They handle rapid changes gracefully. They prevent resource accumulation.
And your users get a faster, more reliable experience.
If this clicked, you’ll love my full course on Advanced Reactivity.
Through exercises and videos, you’ll learn about watchers, effect scopes, custom refs, and more.
It's still being worked on, but if you want to be notified when it's ready, you can sign up to my newsletter for all the updates.