How to Manage Watcher Lifecycles in Vue 3 Composables
- Create all
watch,watchEffect, andcomputedcalls synchronously insidesetup()before anyawait. - Structure async composables so watchers observe reactive refs that async operations populate later, not the reverse.
- Use
<script setup>top-levelawaitto leverage the compiler's automatic instance restoration after each await point. - Capture the stop handle returned by
watch()orwatchEffect()when async or deferred creation is unavoidable. - Register cleanup via
onScopeDispose(Vue 3.2+) to dispose stop handles, covering both component and non-component scopes. - Wrap reactive effects in
effectScope()for non-component contexts such as Pinia plugins, router guards, or test utilities. - Test components through mount/unmount cycles, mutating watched sources after unmount to verify no orphaned watchers persist.
Vue 3's Composition API makes it natural to extract reactive logic into composables, but a single structural mistake can cause watcher memory leaks that grow heap size with each mount/unmount cycle. This article breaks down the exact mechanism that ties watchers to component instances, identifies three common leaking patterns found in real-world composables, and walks through four reliable strategies for preventing and fixing them.
Table of Contents
- How Vue 3 Binds Watchers to Component Instances
- The Three Leaking Patterns in Composables
- Fixing Leaking Watchers: Four Reliable Strategies
- Calling Composables Inside Lifecycle Hooks
- Composable Watcher Safety Checklist
- Quick Debugging Tip: Spotting Orphaned Watchers
- One Rule, Zero Leaks
Prerequisites: Examples were tested on Vue 3.5 (3.5.43). onScopeDispose and effectScope are available from Vue 3.2. Top-level await in <script setup> is compiled with withAsyncContext, which restores the active component instance after each await.
How Vue 3 Binds Watchers to Component Instances
The Active Component Instance and Effect Scope
When a component's setup() function begins executing, Vue sets an internal variable that tracks the currently active component instance. You can access this instance via getCurrentInstance(). Alongside it, Vue creates an effect scope (EffectScope), an internal container that collects every reactive side effect that registers before setup() returns. Calls to watch(), watchEffect(), and computed() all register themselves on this effect scope automatically (note: orphaned computed() refs are GC-eligible when dereferenced, while the persistent re-execution risk primarily applies to watch() and watchEffect()). When the component later unmounts, Vue calls stop() on the scope, which tears down every collected effect in one pass. This is the mechanism behind Vue's automatic cleanup, and it works reliably as long as the effects are created while the active instance is set.
What Happens When the Binding Is Missing
If you call watch() or watchEffect() when no active component instance exists, there is no effect scope to collect the watcher. Vue does not warn about this: in a test against Vue 3.5.43, a watch() created inside a setTimeout callback in setup() produced no console warning. The watcher is fully operational, but it has no owner and no lifecycle hook will dispose it. It keeps running its callback each time the watched source changes, even after the component that logically "owns" it is gone. This matters most when the source outlives the component, such as a module-level ref, a Pinia store, or a route. A watcher on a ref that only the unmounted component can reach is collected along with it.
import { ref, watch } from 'vue'
// Long-lived state: module scope here, but a Pinia store or route behaves the same way.
const price = ref(0)
export default {
setup() {
// ā
Created synchronously inside setup().
// It binds to the component's effect scope and is auto-disposed on unmount.
watch(price, (val) => {
console.log('Bound watcher:', val)
})
setTimeout(() => {
// ā Created after setup() has returned, so no active instance or scope exists.
// Vue does not warn. The watcher runs but is never cleaned up.
watch(price, (val) => {
console.log('Orphaned watcher:', val)
})
}, 1000)
return {}
}
}
// After the component unmounts and price.value changes,
// "Bound watcher" stays silent. "Orphaned watcher" keeps firing.
The Three Leaking Patterns in Composables
Pattern 1: Watcher Inside an Async Callback
A frequently encountered leaking pattern shows up in composables that perform asynchronous work and then set up a watcher based on the result. When a composable written in a plain .js or .ts file uses await or .then(), any code after the async boundary executes in a microtask or callback where the active component instance has already been cleared. The setup() function that invoked the composable returned synchronously before the promise resolved.
// useLivePrice.js ā LEAKING composable
import { ref, watchEffect } from 'vue'
// Note: cleanup imports intentionally omitted to illustrate the leak.
export function useLivePrice(symbol) {
const price = ref(null)
const socket = ref(null)
async function init() {
const config = await fetch(`/api/config/${symbol}`).then(r => r.json())
// ā LEAK: By this line, setup() has long since returned.
// No active component instance exists. This watchEffect is orphaned.
// Note: WebSocket cleanup is also absent hereāaddressed separately from the watcher leak.
watchEffect(() => {
if (price.value !== null) {
socket.value = new WebSocket(config.wsUrl)
socket.value.onmessage = (e) => {
try {
price.value = JSON.parse(e.data).price
} catch {
console.error({ symbol, message: 'Malformed WebSocket message', data: e.data })
}
}
}
})
}
init()
return { price }
}
The annotation on the watchEffect call marks the exact point where context is lost. The init() function fires, setup() returns the reactive ref, and when the promise resolves, the watcher registers against nothing.
Pattern 2: Conditional or Deferred Watcher Creation
Some composables lazily create watchers in response to user actions rather than during setup. Event handlers execute entirely outside the setup scope. A composable that exposes a method like startWatching() and creates a watcher inside it will leak every time the method is called from a click handler or other DOM event.
// Pattern 2 ā Deferred watcher in event handler
import { watch } from 'vue'
export function useSearch(query) {
let stopWatcher = null
function startWatching() {
// ā LEAK: Called from a click handler, not during setup(), so the watcher
// has no owner. Stopping the previous watcher here avoids stacking duplicates,
// but nothing stops the last one when the component unmounts.
if (stopWatcher) {
stopWatcher()
stopWatcher = null
}
stopWatcher = watch(query, (val) => {
console.log('Searching:', val)
})
}
return { startWatching }
}
Pattern 3: Watcher Created in a Non-Setup Utility Function
Shared utility functions that internally call watch() or watchEffect() may be imported and invoked from contexts that have no component instance at all: Pinia store actions, Vue Router navigation guards, or standalone initialization scripts. In these cases there is not merely a timing problem but a complete absence of any component lifecycle.
// Pattern 3 ā Utility function called from a Pinia action
import { watch } from 'vue'
export function syncToLocalStorage(stateRef, key) {
// ā LEAK: No component instance when called from a store action
watch(stateRef, (val) => {
try {
localStorage.setItem(key, JSON.stringify(val))
} catch (e) {
console.warn({ key, message: 'localStorage write failed', error: e.message })
}
})
}
Fixing Leaking Watchers: Four Reliable Strategies
Strategy 1: Keep Composable Calls Synchronous in setup()
The simplest fix, and the one that requires no manual cleanup, is restructuring the composable so that all watch, watchEffect, and computed calls happen synchronously, before any await. Async work can still happen, but the watcher should be established first, observing a reactive ref that the async operation populates later.
// useLivePrice.js ā FIXED composable
import { ref, watchEffect } from 'vue'
export function useLivePrice(symbol) {
const price = ref(null)
const config = ref(null)
// ā
watchEffect is created synchronously during setup().
// It binds to the component's effect scope immediately.
watchEffect((onCleanup) => {
// Guard against empty/missing wsUrl in addition to null config.
if (!config.value?.wsUrl) return
const ws = new WebSocket(config.value.wsUrl)
ws.onmessage = (e) => {
try {
const parsed = JSON.parse(e.data)
if (typeof parsed?.price !== 'number') {
console.warn({ symbol, message: 'Unexpected message shape', data: e.data })
return
}
price.value = parsed.price
} catch {
console.error({ symbol, message: 'Malformed WebSocket message', data: e.data })
}
}
ws.onerror = (e) => {
console.error({ symbol, message: 'WebSocket error', event: e })
}
// Cleanup: close the socket when effect re-runs or scope disposes.
onCleanup(() => ws.close())
})
// The async fetch populates the ref; the already-bound watcher reacts.
fetch(`/api/config/${symbol}`)
.then((r) => {
if (!r.ok) throw new Error(`Config fetch failed: ${r.status}`)
return r.json()
})
.then((c) => { config.value = c })
.catch((err) => {
console.error({ symbol, message: 'Config fetch failed', error: err.message })
})
return { price }
}
// BEFORE: watchEffect created after await ā orphaned, never cleaned up.
// AFTER: watchEffect created synchronously ā auto-disposed on unmount.
The watcher exists from the moment the composable is called. When the async fetch completes, it writes to config, and the already-bound watcher reacts. On unmount, Vue disposes the watcher automatically. The onCleanup callback registered inside watchEffect ensures the previous WebSocket is closed whenever the effect re-runs or the scope is disposed, preventing unbounded open connections.
Strategy 2: Use <script setup> Async Instance Restoration
Vue's <script setup> compiler (@vue/compiler-sfc ā„ 3.2) transforms top-level await expressions with withAsyncContext, so the active component instance is restored after each await point. In a test on Vue 3.5.43, a watcher created after a top-level await was disposed on unmount, while one created after a plain await in an async setup() (without that helper) was orphaned. The restoration only applies to the top level of <script setup>. Nested callbacks such as .then() or functions invoked from within a promise chain do not receive this treatment.
<script setup>
import { ref, watch } from 'vue'
import { useLivePrice } from './useLivePrice'
const data = await fetch('/api/initial').then(r => r.json())
// ā
Safe: <script setup> restores the active instance after top-level await.
// This composable's watchers bind correctly.
const { price } = useLivePrice(data.symbol)
fetch('/api/secondary').then((res) => {
// ā NOT safe: This is a nested .then() callback, not a top-level await.
// The compiler does not restore the instance here.
// Any watchers created inside this callback are orphaned.
// Note: watching a ref wrapping a static value (res.status) is shown here
// to illustrate the registration context issue, not reactivity behavior.
watch(ref(res.status), (val) => {
console.log('Status changed:', val) // Leak
})
})
</script>
The key distinction is the compiler boundary. Top-level await gets transformed; nested asynchronous callbacks do not.
Strategy 3: Manual watch() Stop Handles
When async or deferred watcher creation is genuinely unavoidable, the watch() and watchEffect() functions return a stop handle. Capturing this handle and invoking it during cleanup provides explicit disposal. The onScopeDispose utility is preferable to onUnmounted because it works in both component and non-component effect scopes, making it suitable for composables that might be used inside effectScope() as well.
import { watch, onScopeDispose, getCurrentScope } from 'vue'
export function useSearch(query) {
let stopWatcher = null
function stopWatching() {
if (stopWatcher) {
stopWatcher()
stopWatcher = null
}
}
function startWatching() {
// Prevent multiple concurrent watchers from the same composable instance.
stopWatching()
// Created outside setup (for example from a click handler), but manually managed.
stopWatcher = watch(query, (val) => {
console.log('Searching:', val)
})
}
// onScopeDispose only warns (it does not throw) when no effect scope is active,
// so check for a scope first. Inside a component, the scope is the component's.
if (getCurrentScope()) {
onScopeDispose(stopWatching)
}
// With no active scope (a Pinia action, for example), the caller must call stopWatching().
return { startWatching, stopWatching }
}
Prefer onScopeDispose over onUnmounted when the composable may be consumed inside a standalone effectScope, a Pinia plugin, or a test harness, since onUnmounted works only in component context. Vue 3.5 also accepts onScopeDispose(fn, true) to suppress the warning when no scope is active.
Strategy 4: effectScope() for Library-Level Control
For code that runs entirely outside component context, such as Pinia plugins, router middleware, or test utilities, effectScope() provides a container for grouping and disposing reactive effects. Any watcher created inside scope.run() is collected by the scope, and calling scope.stop() tears down all of them at once.
import { effectScope, ref, watchEffect } from 'vue'
export function createCountScope() {
const scope = effectScope()
const count = ref(0)
try {
scope.run(() => {
// This watchEffect is owned by the scope, not a component.
watchEffect(() => {
console.log('Count is:', count.value)
})
})
} catch (err) {
// Ensure partial scope is torn down on initialization failure.
scope.stop()
throw err
}
return {
count,
dispose: () => scope.stop(),
}
}
This mirrors the mechanism Vue uses internally for component instance cleanup, exposed as a public API for non-component contexts. The try/catch around scope.run() ensures that if the callback throws, scope.stop() is still called to avoid leaving a partially initialized scope with leaked effects.
Calling Composables Inside Lifecycle Hooks
Why onMounted and Other Hooks Preserve Context
Lifecycle hook callbacks like onMounted, onUpdated, and onBeforeUnmount execute with the registering component's instance still set as the active instance. Vue registers lifecycle hooks synchronously during setup(), and when it invokes the callbacks later, it restores the component instance before calling them. In a test on Vue 3.5.43, a watcher created inside an onMounted callback was disposed when the component unmounted, so no manual cleanup was needed for the watcher itself. Older Vue versions may not restore the effect scope in hooks, so if you support them, add an explicit stop handle. Cleanup for non-reactive resources such as observers and sockets is still your job.
import { onMounted, ref } from 'vue'
import { useResizeObserver } from './useResizeObserver'
export default {
setup() {
const container = ref(null)
onMounted(() => {
// Vue restores the active component instance before calling
// onMounted callbacks, so watchers created here are disposed on unmount
// (tested on Vue 3.5.43). The observer itself is not a watcher, so
// useResizeObserver should still call observer.disconnect() in
// onScopeDispose to release it.
useResizeObserver(container)
})
return { container }
}
}
Hooks restore the instance, but that does not clean up non-reactive resources such as observers, sockets, and timers. Composables called inside lifecycle hooks should register those with onScopeDispose, as in Strategy 3.
Composable Watcher Safety Checklist
Apply this checklist during code review to catch watcher lifecycle issues before they reach production:
- ā
Is the composable called synchronously inside
setup()or<script setup>? - ā
Are all
watch/watchEffect/computedcalls executed before anyawait? - ā
If a watcher must be created asynchronously, is the stop handle captured and disposed via
onScopeDispose? - ā
Confirm that an
effectScopewraps the effects if the composable runs outside a component (store, plugin, test). - ā
Are non-reactive resources (observers, sockets, timers) released through
onScopeDisposeor an explicit stop handle? - ā
Verify the composable does not create watchers inside
.then(),setTimeout, or event handler callbacks without manual cleanup. - ā Has the component been tested through multiple mount/unmount cycles to verify no orphaned watchers persist?
Adding this checklist to pull request review templates gives teams a concrete, repeatable gate for catching leaks before they accumulate in long-running applications.
Quick Debugging Tip: Spotting Orphaned Watchers
Vue does not warn when a watcher is created without an active instance, so you will not see a console message for these leaks. The reliable check is a mount/unmount cycle test: mount the component, trigger the async code path that creates the watcher, unmount the component, and then mutate the watched source. If a console.log sentinel inside the watcher callback still fires after unmount, the watcher is orphaned.
One Rule, Zero Leaks
The core principle is straightforward: bind reactive effects synchronously during setup, and clean up manually when synchronous binding is impossible. Keeping composable calls synchronous is the default and best approach. That alone eliminates most leaks. Inside <script setup>, the compiler restores the instance after top-level await, so watchers created there are still owned by the component, but only top-level await is covered. Lifecycle hooks also keep the component's scope in Vue 3.5. For unavoidable async or deferred creation, capturing and disposing stop handles through onScopeDispose provides explicit control. For non-component contexts, effectScope() delivers the same grouping and teardown semantics that components get automatically.
Run the checklist above against existing composables in any Vue 3 codebase. Each fix is a one-to-five-line change that stops your application from bleeding memory on every navigation.

