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.

In that quick shuffle of state, overlapping effects start to accumulate, producing spurious network traffic and odd timing edges that are surprisingly hard to reason about during a debug session.

The 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)
})

Change searchQuery five times quickly, and 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 most 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.

Think of it as a precise pre-run hook that executes synchronously before the next effect, giving you a reliable moment to dismantle whatever you previously set up (timers, listeners, in-flight requests).

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.

This eliminates memory leaks and duplicate API calls, and it curbs unnecessary performance costs.

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.

(If you add timestamped logs, you'll see cleanup run immediately before the subsequent execution, which is a helpful sanity check during debugging.)

Real-World Cleanup Examples

Let's look at some common scenarios where you need cleanup.

This is a classic example of a debounced search:

search.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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, rapid typing fans out multiple API calls, slower responses can arrive late and clobber fresher data, and you end up hammering the same endpoint repeatedly.

Event Listener Management

We can also use onCleanup to manage event listeners:

window-events.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 (you might only notice it after opening and closing the modal repeatedly).

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 improve ergonomics.

However, Vue 3.5 introduced the onWatcherCleanup composable to solve this problem. Now you can register multiple cleanup functions independently:

multiple-cleanup.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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. Treat this as a hard boundary; 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:

cleanup-helpers.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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:

clean-watcher.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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 and 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:

  • Creating and using effect scopes
  • Temporary effect scopes
  • Long-running effect scopes
  • Using effect scopes to manage memory and resources
  • And more!

Check it out here: Advanced Reactivity