Bulletproof Watchers in Vue

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 changes
watch(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.

Meet onCleanup: Your Cleanup Superhero

Vue 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 again
onCleanup(() => {
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.

Real-World Cleanup Examples

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 immediately
searchResults.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!

Event Listener Management

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

The Vue 3.5 Upgrade: Multiple Cleanup Functions (Vue 3.5+)

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 runs
watch(data, (value, oldValue, onCleanup) => {
const timer1 = setTimeout(() => {}, 1000)
onCleanup(() => clearTimeout(timer1)) // This gets overwritten
const 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 save
const saveTimer = setTimeout(() => {
saveUserPreferences(id)
}, 1000)
onWatcherCleanup(() => clearTimeout(saveTimer))
// Set up an activity tracker
const activityTimer = setInterval(() => {
trackUserActivity(id)
}, 30000)
onWatcherCleanup(() => clearInterval(activityTimer))
// Set up an API request
const 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.

Limitations of 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.

1. The Async Barrier: Only Works Before First 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 await
watch(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 await
watch(dataId, async (newId) => {
const controller = new AbortController()
// Register cleanup before any await
onWatcherCleanup(() => {
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 parameter
onCleanup(() => controller.abort())
})

2. Context Dependency

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 context
onWatcherCleanup(() => {
// Which watcher is this for?
})
// ✅ This works: inside watcher context
function setupCleanup() {
const timer = setTimeout(() => {}, 1000)
onWatcherCleanup(() => clearTimeout(timer))
}

Building Reusable Cleanup Helpers

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 tracking
useTimeout(() => {
trackUserView(user.id)
}, 500)
// Keyboard shortcuts for this user's role
if (user.role === 'admin') {
useEventListener(
window,
'keydown',
handleAdminShortcuts
)
}
// Load user preferences
useAbortableRequest(`/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.

Why Cleanup Matters More Than You Think

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:

  • Search results appear out of order.
  • Event handlers fire multiple times.
  • API responses update the UI with stale data.

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.

Go deeper with Advanced Reactivity

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.