Vue 3 Composition API: Stop Watcher Memory Leaks in Composables

SitePoint TeamPublished inJavaScript·Programming·Web·
October 9, 2026
·Updated:October 9, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
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
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'const price =ref(0)exportdefault{setup(){watch(price,(val)=>{console.log('Bound watcher:', val)})setTimeout(()=>{watch(price,(val)=>{console.log('Orphaned watcher:', val)})},1000)return{}}}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.
import{ ref, watchEffect }from'vue'exportfunctionuseLivePrice(symbol){const price =ref(null)const socket =ref(null)asyncfunctioninit(){const config =awaitfetch(`/api/config/${symbol}`).then(r=> r.json())watchEffect(()=>{if(price.value!==null){socket.value=newWebSocket(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.
import{ watch }from'vue'exportfunctionuseSearch(query){let stopWatcher =nullfunctionstartWatching(){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.
import{ watch }from'vue'exportfunctionsyncToLocalStorage(stateRef, key){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.
import{ ref, watchEffect }from'vue'exportfunctionuseLivePrice(symbol){const price =ref(null)const config =ref(null)watchEffect((onCleanup)=>{if(!config.value?.wsUrl)returnconst ws =newWebSocket(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 })}onCleanup(()=> ws.close())})fetch(`/api/config/${symbol}`).then((r)=>{if(!r.ok)thrownewError(`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 }}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.
<scriptsetup>import{ ref, watch }from'vue'import{ useLivePrice }from'./useLivePrice'const data =awaitfetch('/api/initial').then(r=> r.json())const{ price }=useLivePrice(data.symbol)fetch('/api/secondary').then((res)=>{watch(ref(res.status),(val)=>{console.log('Status changed:', val)})})</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'exportfunctionuseSearch(query){let stopWatcher =nullfunctionstopWatching(){if(stopWatcher){stopWatcher()stopWatcher =null}}functionstartWatching(){stopWatching()stopWatcher =watch(query,(val)=>{console.log('Searching:', val)})}if(getCurrentScope()){onScopeDispose(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'exportfunctioncreateCountScope(){const scope =effectScope()const count =ref(0)try{scope.run(()=>{watchEffect(()=>{console.log('Count is:', count.value)})})}catch(err){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'exportdefault{setup(){const container =ref(null)onMounted(()=>{useResizeObserver(container)})return{ container }}}Hooks restore the instance, but that does not clean up non-reactive re 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 watchedl 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.
Sharing our passion for building incredible internet things.


