<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eve</title>
    <description>The latest articles on DEV Community by Eve (@eveko).</description>
    <link>https://dev.to/eveko</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3950706%2Fb868783d-5ae8-437c-ae10-1db1cf48aadb.png</url>
      <title>DEV Community: Eve</title>
      <link>https://dev.to/eveko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eveko"/>
    <language>en</language>
    <item>
      <title>Vue 3 Cleans Up After Itself — Until It Can't</title>
      <dc:creator>Eve</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:38:13 +0000</pubDate>
      <link>https://dev.to/eveko/vue-3-cleans-up-after-itself-until-it-cant-116o</link>
      <guid>https://dev.to/eveko/vue-3-cleans-up-after-itself-until-it-cant-116o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://eveko.dev/articles/vue-3-memory-leaks?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=vue-3-memory-leaks" rel="noopener noreferrer"&gt;eveko.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Vue 3 disposes the reactive effects it creates. A &lt;code&gt;watch&lt;/code&gt; or a &lt;code&gt;watchEffect&lt;/code&gt; declared in &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; is bound to the component instance and torn down the moment that component unmounts — &lt;a href="https://vuejs.org/guide/essentials/watchers.html#stopping-a-watcher" rel="noopener noreferrer"&gt;no cleanup code required&lt;/a&gt;. So the memory leaks that reach production mostly don't live in the reactivity system. They live in the gap between what Vue owns and what a developer hands to something that outlives the component.&lt;/p&gt;

&lt;p&gt;A user opens the reports view, goes back to the list, and opens it again. Each visit starts a &lt;code&gt;setInterval&lt;/code&gt; polling every five seconds; each exit leaves it running. Nothing ever stops them, so the pollers accumulate across the day — every one still firing, every one still holding the response it fetched last, and the tab's footprint climbing from morning onward. By afternoon the filter box has started to stutter. A second failure class is louder. On a memory-constrained tab (a mid-range Android, an embedded webview, a TV browser) the page doesn't get slow. The browser kills it.&lt;/p&gt;

&lt;p&gt;Both failures trace to the same root, and it isn't Vue. A 2026 static-analysis pass across 500 public repositories (&lt;a href="https://stackinsight.dev/blog/memory-leak-empirical-study/" rel="noopener noreferrer"&gt;the StackInsight study&lt;/a&gt;) convicts the unglamorous resources. It is self-published, its detectors adapted from the author's own commercial scanner (Code Evolution Lab, which the page also pitches), and it admits that formal precision and recall were never measured. Of the 15,750 leak sites it flagged in Vue repositories, the Vue-specific "missing watch stop handle" pattern accounts for roughly a quarter, though that detector flags uncaptured stop handles — including synchronous &lt;code&gt;setup()&lt;/code&gt; watchers that Vue disposes anyway. Nearly everything else is a resource Vue never owned rather than anything its reactivity system created. The reactivity system Vue developers are drilled to fear is the smaller share of the problem.&lt;/p&gt;

&lt;p&gt;This is a field guide to where Vue 3 leaks actually come from, and the one rule that prevents all of them. One thing is out of scope. Server-side leaks (Nuxt SSR contexts, request-scoped store state that never gets garbage-collected between requests) are a genuinely different problem with a different shape; Vue's SSR guide covers the mechanism as &lt;a href="https://vuejs.org/guide/scaling-up/ssr.html#cross-request-state-pollution" rel="noopener noreferrer"&gt;cross-request state pollution&lt;/a&gt;, and &lt;a href="https://nuxt.com/docs/4.x/getting-started/state-management" rel="noopener noreferrer"&gt;Nuxt's state-management docs&lt;/a&gt; indict the module-scope &lt;code&gt;ref&lt;/code&gt; as the specific trap. Start with those. Confirming that a tab is leaking at all, rather than just using memory, is a skill of its own; that comes near the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vue already cleans up the part everyone worries about
&lt;/h2&gt;

&lt;p&gt;The mental model worth carrying is one sentence: &lt;strong&gt;Vue disposes the effects it owns, and owns nothing else.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every component's &lt;code&gt;setup()&lt;/code&gt; runs inside an effect scope, an internal container that collects the reactive effects created during synchronous execution. The official docs insist on it: watchers "declared synchronously inside &lt;code&gt;setup()&lt;/code&gt; or &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; are bound to the owner component instance, and will be automatically stopped when the owner component is unmounted. In most cases, you don't need to worry about stopping the watcher yourself" (per the &lt;a href="https://vuejs.org/guide/essentials/watchers.html#stopping-a-watcher" rel="noopener noreferrer"&gt;Vue watchers guide&lt;/a&gt;). A &lt;code&gt;watch&lt;/code&gt; that fires on every store mutation, a &lt;code&gt;watchEffect&lt;/code&gt; that re-runs on every keystroke — both vanish cleanly on unmount, because Vue was holding the handle the whole time.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;computed&lt;/code&gt; reaches the same place by a different route. Since 3.5 it isn't registered on the scope at all; it drops its dependencies once it loses every subscriber, and the maintainers deny it needs stopping at all: a post-3.5 computed is, in their words, "self-disposing" (per &lt;a href="https://github.com/vuejs/core/issues/11886" rel="noopener noreferrer"&gt;vuejs/core#11886&lt;/a&gt;). Either way, nothing is left for you to stop.&lt;/p&gt;

&lt;p&gt;So the reactivity system is not the threat. The threat is everything Vue never saw you create. Three categories cover almost all of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Manual browser APIs&lt;/strong&gt; — &lt;code&gt;setInterval&lt;/code&gt;, &lt;code&gt;addEventListener&lt;/code&gt;, &lt;code&gt;requestAnimationFrame&lt;/code&gt;, &lt;code&gt;IntersectionObserver&lt;/code&gt;, &lt;code&gt;WebSocket&lt;/code&gt;. Vue doesn't wrap these. It doesn't know they exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party library instances&lt;/strong&gt; — a chart, a map, a rich-text editor. Each holds its own canvases, listeners, and buffers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;References handed to something longer-lived&lt;/strong&gt; — a module-scoped array, a global event bus, a Pinia store that keeps pushing component data and never lets go.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The skeptic dismisses the whole exercise by paragraph three: isn't this just "clean up after yourself," the same discipline every framework and vanilla page has always demanded? Partly, yes — the principle is universal. What's specific to Vue 3 is the part it handles for free (the owned effects, disposed without a line of cleanup code) and the seams it gives you for the rest: &lt;code&gt;onUnmounted&lt;/code&gt;, &lt;code&gt;onScopeDispose&lt;/code&gt;, &lt;code&gt;effectScope&lt;/code&gt;, &lt;code&gt;onWatcherCleanup&lt;/code&gt;. The principle is old. The tools are new, and most of this article is about using them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the leaks actually live
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://stackinsight.dev/blog/memory-leak-empirical-study/" rel="noopener noreferrer"&gt;StackInsight study&lt;/a&gt; ranked all 55,864 of its flagged leak sites by category. These are the top five of nine, counted across the full React/Vue/Angular corpus; the shape of the ranking is the whole argument:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Share of leak sites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing timer cleanup&lt;/td&gt;
&lt;td&gt;43.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing event listener removal&lt;/td&gt;
&lt;td&gt;19.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing subscription cleanup&lt;/td&gt;
&lt;td&gt;13.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing effect cleanup&lt;/td&gt;
&lt;td&gt;9.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing watch stop handle&lt;/td&gt;
&lt;td&gt;7.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One row needs unpacking before the shape reads correctly. &lt;code&gt;Missing watch stop handle&lt;/code&gt; is a Vue-only detector, so its 7.1% is measured against a corpus that is roughly seventy per cent React and Angular; against Vue's own 15,750 findings, the same pattern is about a quarter. The &lt;a href="https://stackinsight.dev/blog/memory-leak-empirical-study/" rel="noopener noreferrer"&gt;study&lt;/a&gt; concedes the skew plainly: the sample was "weighted toward React".&lt;/p&gt;

&lt;p&gt;This is a single study, not a law of nature, but the direction is hard to argue with. The top three rows, the ones Vue's auto-disposal does nothing for, are more than three-quarters of the corpus. Inside Vue's own findings, three of every four flagged sites are something other than the watcher pattern. The ranking quietly mocks the priorities most leak articles encode. A developer who memorizes the entire &lt;code&gt;effectScope&lt;/code&gt; API and still writes &lt;code&gt;setInterval&lt;/code&gt; without &lt;code&gt;clearInterval&lt;/code&gt; has optimized the quarter and shipped the rest. The tooling asymmetry matches: &lt;code&gt;eslint-plugin-vue&lt;/code&gt; ships &lt;a href="https://eslint.vuejs.org/rules/no-watch-after-await.html" rel="noopener noreferrer"&gt;&lt;code&gt;vue/no-watch-after-await&lt;/code&gt;&lt;/a&gt; in its essential preset, which reports a &lt;code&gt;watch&lt;/code&gt; registered after an &lt;code&gt;await&lt;/code&gt;, and ships nothing at all for a &lt;code&gt;setInterval&lt;/code&gt; that never gets cleared. Closing that gap is a house rule, not a plugin install: &lt;a href="https://eslint-react.xyz/docs/rules/web-api-no-leaked-interval" rel="noopener noreferrer"&gt;&lt;code&gt;@eslint-react&lt;/code&gt;'s &lt;code&gt;web-api-no-leaked-interval&lt;/code&gt;&lt;/a&gt; does check that a &lt;code&gt;setInterval&lt;/code&gt; is paired with a &lt;code&gt;clearInterval&lt;/code&gt;, but reports only inside &lt;code&gt;useEffect&lt;/code&gt; callbacks and stays silent in &lt;code&gt;onMounted&lt;/code&gt;. What a Vue team can enforce is a &lt;a href="https://eslint.org/docs/latest/rules/no-restricted-syntax" rel="noopener noreferrer"&gt;&lt;code&gt;no-restricted-syntax&lt;/code&gt;&lt;/a&gt; selector catching the shape that is unfixable rather than merely unfixed: a &lt;code&gt;setInterval&lt;/code&gt; whose return value is discarded, leaving no id for any &lt;code&gt;clearInterval&lt;/code&gt; to take. That bans one call; it never proves a teardown, because a selector cannot tie a &lt;code&gt;clearInterval&lt;/code&gt; to the id a particular &lt;code&gt;setInterval&lt;/code&gt; returned.&lt;/p&gt;

&lt;p&gt;So the rest of this is organized by what Vue can't see, roughly in order of how often it bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manual browser resources: timers, listeners, subscriptions, observers
&lt;/h2&gt;

&lt;p&gt;These share one fix: whatever you start in &lt;code&gt;onMounted&lt;/code&gt;, stop in &lt;code&gt;onUnmounted&lt;/code&gt;. The interesting part is the ways the stop quietly fails to happen.&lt;/p&gt;

&lt;p&gt;Start with listeners, because the failure has a sharp edge most people hit once. Watch the handler reference across the two calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerWidth&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;onUnmounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// a second arrow — a different function, so this removes nothing&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerWidth&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onResize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerWidth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onResize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;onUnmounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onResize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;avoid&lt;/code&gt; version is unfixable, not just unfixed. &lt;code&gt;removeEventListener&lt;/code&gt; matches by reference, and its cleanup call removes nothing: the second arrow is a different function from the first, however identical the two look. The window outlives the component, so the listener (and the &lt;code&gt;layout&lt;/code&gt; closure behind it) stays registered for the life of the tab. Every remount adds another, and each one still fires. After a dozen visits a single resize event runs &lt;code&gt;layout&lt;/code&gt; a dozen times, eleven of them on behalf of components that no longer exist.&lt;/p&gt;

&lt;p&gt;Observers and sockets are the same story with a different verb. An &lt;code&gt;IntersectionObserver&lt;/code&gt; watching a sentinel for infinite scroll keeps its target (and the component scope around it) alive until you &lt;code&gt;disconnect()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onIntersect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sentinel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;observer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;IntersectionObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onIntersect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sentinel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;onUnmounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;observer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;WebSocket&lt;/code&gt; wants &lt;code&gt;close()&lt;/code&gt;, an &lt;code&gt;EventSource&lt;/code&gt; wants &lt;code&gt;close()&lt;/code&gt;, a &lt;code&gt;ResizeObserver&lt;/code&gt; wants &lt;code&gt;disconnect()&lt;/code&gt;, a &lt;code&gt;requestAnimationFrame&lt;/code&gt; loop wants &lt;code&gt;cancelAnimationFrame&lt;/code&gt;. Same rule, same seam. Timers, the most common leak in the study's corpus, get the canonical treatment in the recommendation block below; the fix is &lt;code&gt;clearInterval&lt;/code&gt;, and the trap is forgetting that the interval's callback keeps its entire closure alive between ticks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two watcher leaks
&lt;/h2&gt;

&lt;p&gt;Watchers leak in two distinct ways, and conflating them is why the advice around them is muddled. Only one is the reactivity system's.&lt;/p&gt;

&lt;p&gt;The first leak isn't the watcher — it's what the watcher &lt;em&gt;starts&lt;/em&gt;. A watcher that opens a socket, registers a listener, or fires a request on every change, without tearing down the previous one, stacks resources on each run. The rule worth memorizing: if a watcher starts something (a listener, a request, a timer), it must also stop it. Vue 3.5 added &lt;code&gt;onWatcherCleanup&lt;/code&gt; for exactly this: a teardown that runs when the watcher is invalidated and about to re-run (per the Vue docs on &lt;a href="https://vuejs.org/guide/essentials/watchers.html#side-effect-cleanup" rel="noopener noreferrer"&gt;side effect cleanup&lt;/a&gt;). It fires on the way out, too. Vue registers the cleanup as the effect's &lt;a href="https://github.com/vuejs/core/blob/main/packages/reactivity/src/watch.ts" rel="noopener noreferrer"&gt;&lt;code&gt;onStop&lt;/code&gt; hook&lt;/a&gt;, so unmounting the component closes the last socket as well as every one superseded mid-flight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;openSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onWatcherCleanup&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;openSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handleMessage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onWatcherCleanup&lt;/code&gt; lands in Vue 3.5+, and it has to be called during the watcher's synchronous execution — never after an &lt;code&gt;await&lt;/code&gt;. On older versions, and past that constraint, the third callback argument (&lt;code&gt;onCleanup&lt;/code&gt;) binds to the watcher instance instead. When the side effect is a &lt;code&gt;fetch&lt;/code&gt;, the cleanup is an &lt;code&gt;AbortController&lt;/code&gt;, and the same cleanup seam carries it — the deeper mechanics of racing requests get their own treatment in the piece on &lt;a href="https://eveko.dev/articles/cancelling-api-requests-vue-3" rel="noopener noreferrer"&gt;cancelling API requests in Vue 3&lt;/a&gt;, but nothing in this section waits on it. The leak angle is the simpler half: started, so stop it.&lt;/p&gt;

&lt;p&gt;The second leak is the only one that actually lives in the reactivity system, and it's narrow. A watcher created &lt;em&gt;asynchronously&lt;/em&gt; (after an &lt;code&gt;await&lt;/code&gt;, inside a &lt;code&gt;setTimeout&lt;/code&gt;, in a promise callback) is never bound to the component, so it never auto-stops. The &lt;a href="https://vuejs.org/guide/essentials/watchers.html#stopping-a-watcher" rel="noopener noreferrer"&gt;same guide&lt;/a&gt; rejects any ambiguity: a watcher created in an async callback "won't be bound to the owner component and must be stopped manually to avoid memory leaks."&lt;/p&gt;

&lt;p&gt;The honest first move is to not create watchers asynchronously at all — hoist them into synchronous setup and the auto-disposal does the work. When the timing genuinely can't be helped, capture both handles (the timer's and the one &lt;code&gt;watchEffect&lt;/code&gt; returns) and clear them on unmount. The broken shape first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;watchEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Unread: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timeoutId&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;stop&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;timeoutId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watchEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Unread: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;onUnmounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;?.()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timer is the easy half to forget: unmount before it fires and there is no watcher to stop yet, only a callback still queued to create one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handles and references that outlive the component
&lt;/h2&gt;

&lt;p&gt;Two leak sources left, and they're the ones Chrome's heap snapshot tends to surface as "detached" nodes.&lt;/p&gt;

&lt;p&gt;Third-party widgets allocate aggressively — a charting library holds canvases, its own resize listeners, and the dataset you handed it. Vue mounts and unmounts the wrapper component; the library instance underneath neither knows nor cares. The pattern is mechanical: store the instance, call its teardown method on unmount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chart&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chart&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;onUnmounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtler source is a reference you park somewhere long-lived. Module scope is the classic trap: a &lt;code&gt;const&lt;/code&gt; at the top of a file outlives every component that imports it, so anything put into it and never removed grows for the life of the tab. The same applies to a global event bus you never &lt;code&gt;off()&lt;/code&gt;, or a store array that accumulates component data without bound. Module scope in its plainest form is a cache nobody ever prunes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// module scope — and nothing ever removes an entry&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onScopeDispose&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// same Map, same signature — the only change is registering the teardown&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useHistory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;onScopeDispose&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onScopeDispose&lt;/code&gt; rather than &lt;code&gt;onUnmounted&lt;/code&gt; here, because &lt;code&gt;useHistory&lt;/code&gt; is a composable and a composable can run in a scope that is not a component's. Swapping the &lt;code&gt;Map&lt;/code&gt; for a &lt;code&gt;WeakMap&lt;/code&gt; is no substitute for that teardown: WeakMap keys must be &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap/set" rel="noopener noreferrer"&gt;objects or non-registered symbols&lt;/a&gt;, so a string or numeric &lt;code&gt;key&lt;/code&gt; throws a &lt;code&gt;TypeError&lt;/code&gt;, and an object key only hands the entry's release to the collector's schedule instead of unmount. A weak collection fits where the key is an object whose lifetime something else already owns; here it is an identifier the caller supplies.&lt;/p&gt;

&lt;p&gt;The same long-lived-reference trap shows up in Pinia's own &lt;code&gt;$subscribe&lt;/code&gt; and &lt;code&gt;$onAction&lt;/code&gt;. The safe case is the default: registered inside an active effect scope — a component's &lt;code&gt;setup()&lt;/code&gt; included — both bind to that scope and are removed when it disposes (per the Pinia docs on &lt;a href="https://pinia.vuejs.org/core-concepts/state.html" rel="noopener noreferrer"&gt;state&lt;/a&gt; and &lt;a href="https://pinia.vuejs.org/core-concepts/actions.html" rel="noopener noreferrer"&gt;actions&lt;/a&gt;). Detach them (&lt;code&gt;{ detached: true }&lt;/code&gt; on &lt;code&gt;$subscribe&lt;/code&gt;, &lt;code&gt;true&lt;/code&gt; as the second argument to &lt;code&gt;$onAction&lt;/code&gt;), or register them where no scope is active, and the returned unsubscribe function becomes yours to call. The flag is the whole difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCartStore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// detached opts out of the scope cleanup — and nothing replaces it&lt;/span&gt;
&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saveCart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;detached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCartStore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;// no detach: the subscription dies with the scope that registered it&lt;/span&gt;
&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saveCart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reference parked in long-lived scope has a second failure mode: a handler that closes over a template ref keeps that DOM node detached-but-alive, out of the garbage collector's reach after unmount. The reference is the leak. Clear it when the component goes; when there is no component, the next section supplies the hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composables: give the effects a scope to die with
&lt;/h2&gt;

&lt;p&gt;Composables complicate the mental model in one specific way. &lt;code&gt;onUnmounted&lt;/code&gt; needs a component instance; &lt;code&gt;onScopeDispose&lt;/code&gt; needs an effect scope. Every component setup is a scope, but not every scope is a component — a composable can just as easily run inside a Pinia store's setup or a hand-rolled &lt;code&gt;effectScope()&lt;/code&gt;, where there is no instance of its own for &lt;code&gt;onUnmounted&lt;/code&gt; to bind to. A composable that relies on &lt;code&gt;onUnmounted&lt;/code&gt; for teardown is betting on a component being there to hold it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;onScopeDispose&lt;/code&gt; is the fix: it registers teardown on the &lt;em&gt;current effect scope&lt;/em&gt; rather than the component instance, so it fires for any scope — component setup or otherwise. The docs frame it as "a non-component-coupled replacement of &lt;code&gt;onUnmounted&lt;/code&gt; in reusable composition functions" (&lt;a href="https://vuejs.org/api/reactivity-advanced.html#onscopedispose" rel="noopener noreferrer"&gt;Reactivity API: Advanced&lt;/a&gt;). One limit rides along: the scope has to be &lt;em&gt;active&lt;/em&gt;. Outside any scope at all — module top level, a router guard, a Vue plugin's &lt;code&gt;install()&lt;/code&gt; — it registers nothing and warns in dev only, silently in production, exactly as &lt;code&gt;onUnmounted&lt;/code&gt; does. Neither hook rescues that case; code that runs there owns its teardown outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;onUnmounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onScopeDispose&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;onScopeDispose&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when a composable spins up several effects that should be disposed as a unit (or you need reactivity outside any component), tracking each stop handle by hand is the trap &lt;code&gt;effectScope&lt;/code&gt; exists to remove. It's the same machinery the RFC authors lifted out of Vue's component internals precisely because, outside a component, "it's laborious to manually collect all the effects" and "easy to forget", which "might result in memory leakage" (per &lt;a href="https://github.com/vuejs/rfcs/blob/master/active-rfcs/0041-reactivity-effect-scope.md" rel="noopener noreferrer"&gt;RFC 0041&lt;/a&gt;). Watch how many handles the caller has to remember in the first version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// every effect returns its own stop handle, tracked by hand&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stopSync&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stopReport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;watchEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;teardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;stopSync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;stopReport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;effectScope&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;effectScope&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;watchEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;teardown&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// one call disposes every effect in the scope&lt;/span&gt;
  &lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The counter: "Vue cleans up, and VueUse handles the rest"
&lt;/h2&gt;

&lt;p&gt;Skeptics deride all of this as solved-problem fear-mongering, and the objection comes in two parts worth taking seriously.&lt;/p&gt;

&lt;p&gt;The first part is correct on the facts: Vue 3 &lt;em&gt;does&lt;/em&gt; auto-dispose, so a category of older "always stop your watchers" advice is genuinely outdated. That much is granted. But auto-disposal covers a quarter of Vue's flagged sites, not the three-quarters that were never Vue's to dispose — and not even all of that quarter, since the async-registration case sits inside it and auto-disposal never reached there either. The auto-disposal that practitioners cite as the reason not to worry is doing nothing for the timers, listeners, and subscriptions on the other side of that line. This is the design verdict, and it isn't a knock on Vue: no framework can reclaim a resource it never saw allocated. The framework concedes that boundary by design. Auto-disposing owned effects is the right call. It just bounds the problem to exactly the resources Vue has a handle on, and leaves the rest to you by necessity, not oversight.&lt;/p&gt;

&lt;p&gt;The second part is the better argument: don't hand-roll any of this; reach for &lt;a href="https://vueuse.org/core/useEventListener/" rel="noopener noreferrer"&gt;VueUse&lt;/a&gt;, whose composables wire teardown in for you. It's the right default, not a crutch. &lt;code&gt;useEventListener&lt;/code&gt; registers on mount, and its docs vouch for the behavior plainly: it runs "&lt;code&gt;removeEventListener&lt;/code&gt; automatically on unmounted". &lt;a href="https://vueuse.org/shared/useIntervalFn/" rel="noopener noreferrer"&gt;&lt;code&gt;useIntervalFn&lt;/code&gt;&lt;/a&gt; does the same for timers, though you have to read its &lt;a href="https://github.com/vueuse/vueuse/blob/main/packages/shared/useIntervalFn/index.ts" rel="noopener noreferrer"&gt;source&lt;/a&gt; to see why: it hands &lt;code&gt;pause&lt;/code&gt; straight to &lt;code&gt;tryOnScopeDispose&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEventListener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useIntervalFn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vueuse/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;useEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onResize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;useIntervalFn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the listener section and the polling interval, both deleted. No &lt;code&gt;onMounted&lt;/code&gt;, no &lt;code&gt;onUnmounted&lt;/code&gt;, no reference-matching trap, no timer id to keep. The rule still matters, because the library is the rule &lt;em&gt;applied&lt;/em&gt;, not a substitute for understanding it. Both composables tie their teardown to the current effect scope (the same seam this article is about), and the protection ends at the edge of what VueUse wraps. The niche charting library, the module-scoped cache: the moment a developer touches a resource no composable covers, they own teardown again, and the quiet assumption that VueUse must have handled it is how that leak ships. Reach for VueUse. Understand why it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection: confirm the leak before you chase it
&lt;/h2&gt;

&lt;p&gt;Suspecting a leak and proving one are different activities, and the gap between them is where hours disappear. The recognition signal is blunt: mount a component, unmount it a dozen times, and if its instances are still in the heap after garbage collection, something is holding them. Reaching for &lt;a href="https://devtools.vuejs.org/getting-started/features" rel="noopener noreferrer"&gt;Vue DevTools&lt;/a&gt; first is a dead end: its Components tab walks the mounted tree and skips instances already unmounted, so a leaked instance is precisely the one thing it cannot show. The proof lives in Chrome DevTools' Memory panel, and the workflow is mechanical (per &lt;a href="https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots/" rel="noopener noreferrer"&gt;the Chrome DevTools heap-snapshot guide&lt;/a&gt;): take a snapshot, navigate into the suspect component and back out several times, take a second snapshot, and switch it to Comparison view. DevTools runs a garbage collection before every snapshot, so whatever is still standing in the second one is pinned by something. &lt;a href="https://developer.chrome.com/docs/devtools/memory-problems/" rel="noopener noreferrer"&gt;Filter the class list by "Detached"&lt;/a&gt; to find DOM kept alive by stale references, then read the Retainers pane to see exactly what's holding on.&lt;/p&gt;

&lt;p&gt;For teams that want deterministic checks in CI, &lt;a href="https://facebook.github.io/memlab/" rel="noopener noreferrer"&gt;Meta's &lt;code&gt;memlab&lt;/code&gt;&lt;/a&gt; automates the snapshot-diff loop; for ad-hoc debugging, the manual panel is the first thing to reach for.&lt;/p&gt;

&lt;p&gt;Below memlab sits a cheaper rung: a unit test asserting the teardown ran. A green test vouches only for the call, not for the reclamation. It says &lt;code&gt;clearInterval&lt;/code&gt; fired with the id &lt;code&gt;setInterval&lt;/code&gt; handed back, which makes it a regression guard on a leak already found rather than a way to find one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @vitest-environment happy-dom&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vue/test-utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vitest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Poller&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Poller.vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clears its interval on unmount&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setSpy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spyOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setInterval&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clearSpy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spyOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;globalThis&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clearInterval&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Poller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setSpy&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledOnce&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="nx"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unmount&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clearSpy&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setSpy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Poller&lt;/code&gt; is any component that starts an interval in &lt;code&gt;onMounted&lt;/code&gt;. Vitest defaults to a &lt;code&gt;node&lt;/code&gt; environment, so &lt;code&gt;mount&lt;/code&gt; needs &lt;code&gt;happy-dom&lt;/code&gt; or &lt;code&gt;jsdom&lt;/code&gt; switched on. Skip &lt;code&gt;vi.useFakeTimers()&lt;/code&gt;: it replaces the timer globals, and installing it after the spies leaves them recording nothing. The &lt;code&gt;setInterval&lt;/code&gt; assertion is what keeps the test honest, since without it a component that starts no timer at all passes. Restore the spies as well — &lt;code&gt;vi.restoreAllMocks()&lt;/code&gt; in an &lt;code&gt;afterEach&lt;/code&gt;, or &lt;code&gt;restoreMocks: true&lt;/code&gt; — because &lt;code&gt;vi.spyOn&lt;/code&gt; on an already-spied global hands back the same spy, and a second test in the file would inherit the first one's call log.&lt;/p&gt;

&lt;p&gt;None of that watches production. The nearest thing to a field reading is &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Performance/measureUserAgentSpecificMemory" rel="noopener noreferrer"&gt;&lt;code&gt;performance.measureUserAgentSpecificMemory()&lt;/code&gt;&lt;/a&gt;: Chromium-only, still a &lt;a href="https://wicg.github.io/performance-measure-memory/" rel="noopener noreferrer"&gt;WICG draft&lt;/a&gt; rather than a W3C standard, and rejected with a &lt;code&gt;SecurityError&lt;/code&gt; unless the document is cross-origin isolated under COOP and COEP. Its predecessor &lt;code&gt;performance.memory&lt;/code&gt; is &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory" rel="noopener noreferrer"&gt;deprecated and non-standard&lt;/a&gt;, not a fallback. The out-of-memory kill is observable only after the fact and only out-of-band: declare a &lt;code&gt;crash-reporting&lt;/code&gt; or &lt;code&gt;default&lt;/code&gt; endpoint in a &lt;code&gt;Reporting-Endpoints&lt;/code&gt; header and the browser posts a &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CrashReport" rel="noopener noreferrer"&gt;&lt;code&gt;crash&lt;/code&gt; report&lt;/a&gt; once the renderer is gone, tagged &lt;code&gt;reason: "oom"&lt;/code&gt; when the browser knows why. That mechanism is Chromium-only and defined in no published specification, which is the state of the art rather than a recommendation. Nothing inside a page that just exhausted memory survives to report on itself, which is why Sentry's maintainers &lt;a href="https://github.com/getsentry/sentry-javascript/issues/5280" rel="noopener noreferrer"&gt;deny it is detectable from inside the SDK&lt;/a&gt;. That endpoint is the only signal that survives the kill.&lt;/p&gt;

&lt;h2&gt;
  
  
  What looks like a leak but isn't
&lt;/h2&gt;

&lt;p&gt;Three patterns set off false alarms worth pre-empting. &lt;code&gt;&amp;lt;KeepAlive&amp;gt;&lt;/code&gt; deliberately retains cached component state: its growth is the feature, not a leak. What it does change is where cleanup goes: a cached component is deactivated rather than unmounted, so anything that should stop while it's hidden belongs in &lt;a href="https://vuejs.org/guide/built-ins/keep-alive.html#lifecycle-of-cached-instance" rel="noopener noreferrer"&gt;&lt;code&gt;onDeactivated&lt;/code&gt;&lt;/a&gt;. Development-mode memory growth from HMR is not a production signal. And a Pinia store that grows isn't Pinia leaking — it's holding exactly what the application told it to hold, which means the fix is in the code that parks data there, not in the store.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;The discipline reduces to one habit: at the moment you create a resource, ask whether Vue can see it. If it can't, you own its end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best practice:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In general:&lt;/strong&gt; Vue disposes the effects it owns. You own the teardown of anything you create that can outlive the component — if Vue can't see it, it can't clean it up. Reach for VueUse where it already wraps the resource; own the teardown where it doesn't. Two questions settle most rows below: what did you create, and whose scope does it die with — &lt;code&gt;onUnmounted&lt;/code&gt; inside a component, &lt;code&gt;onScopeDispose&lt;/code&gt; in any other effect scope a composable might run in. The exceptions are the two rows with a seam of their own: &lt;code&gt;onWatcherCleanup&lt;/code&gt; for a side effect started inside a watcher, &lt;code&gt;effectScope&lt;/code&gt; for several effects that must die as a unit. If &lt;code&gt;&amp;lt;KeepAlive&amp;gt;&lt;/code&gt; caches the component, anything that should stop while it's hidden goes in &lt;code&gt;onDeactivated&lt;/code&gt; — &lt;code&gt;onUnmounted&lt;/code&gt; won't run until the cache drops it. Confirm the leak with a two-snapshot heap comparison before you chase it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Case&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;setInterval&lt;/code&gt; / &lt;code&gt;setTimeout&lt;/code&gt; / &lt;code&gt;requestAnimationFrame&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;clearInterval&lt;/code&gt; / &lt;code&gt;clearTimeout&lt;/code&gt; / &lt;code&gt;cancelAnimationFrame&lt;/code&gt; in &lt;code&gt;onUnmounted&lt;/code&gt; — or VueUse's &lt;code&gt;useIntervalFn&lt;/code&gt; for the timers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;window&lt;/code&gt; / &lt;code&gt;document&lt;/code&gt; / emitter listener&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;removeEventListener&lt;/code&gt; (or the emitter's &lt;code&gt;.off()&lt;/code&gt;) with the same handler reference in &lt;code&gt;onUnmounted&lt;/code&gt; — or &lt;code&gt;useEventListener&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;IntersectionObserver&lt;/code&gt;, &lt;code&gt;WebSocket&lt;/code&gt;, &lt;code&gt;EventSource&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;disconnect()&lt;/code&gt; / &lt;code&gt;close()&lt;/code&gt; in &lt;code&gt;onUnmounted&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A side effect started inside a watcher&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;onWatcherCleanup&lt;/code&gt; (3.5+), or the &lt;code&gt;onCleanup&lt;/code&gt; third argument&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A watcher or effect created after an &lt;code&gt;await&lt;/code&gt; or in a callback&lt;/td&gt;
&lt;td&gt;create it synchronously if you can — otherwise capture the returned stop handle and call it, plus &lt;code&gt;clearTimeout&lt;/code&gt; on the pending timer if a timer is what queued it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A third-party widget instance&lt;/td&gt;
&lt;td&gt;store the handle, call its &lt;code&gt;.destroy()&lt;/code&gt; in &lt;code&gt;onUnmounted&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A composable that may run outside a component&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;onScopeDispose&lt;/code&gt; — it fires for any effect scope, not just component setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several effects to dispose as a unit, or reactivity outside any component&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;effectScope&lt;/code&gt; — one &lt;code&gt;.stop()&lt;/code&gt; disposes every effect inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component data pushed into module / global / store scope&lt;/td&gt;
&lt;td&gt;clear the reference in &lt;code&gt;onUnmounted&lt;/code&gt; — or &lt;code&gt;onScopeDispose&lt;/code&gt; if a composable is what parked it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A detached Pinia &lt;code&gt;$subscribe&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;don't detach unless something outside the component owns its end — and then that owner calls the returned unsubscribe&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one pattern to keep in muscle memory, because timers are the case people forget most — and because it is exactly what &lt;code&gt;useIntervalFn&lt;/code&gt; wraps when you reach for VueUse instead:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;onUnmounted&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It comes down to one reflex: noticing, at the instant of creation, whether the thing you just made is something Vue is holding or something you are. Vue closed the gap it could close. The rest of the gap has your name on it, and closing it was never a matter of memorizing more APIs. It's knowing, for everything you create, whose scope it dies with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://eveko.dev/articles/cancelling-api-requests-vue-3" rel="noopener noreferrer"&gt;Cancelling API Requests in Vue 3 — eveko&lt;/a&gt; — companion piece on aborting in-flight requests, the canonical watcher side effect.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.chrome.com/docs/devtools/memory-problems/" rel="noopener noreferrer"&gt;Chrome DevTools: Fix memory problems — Google&lt;/a&gt; — the &lt;code&gt;Detached&lt;/code&gt; class filter for finding DOM kept alive by stale references.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots/" rel="noopener noreferrer"&gt;Chrome DevTools: Record heap snapshots — Google&lt;/a&gt; — the two-snapshot Comparison workflow and the Retainers pane.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vuejs/core/issues/11886" rel="noopener noreferrer"&gt;computed is no longer controlled by effectScope — vuejs/core issue #11886, 2024&lt;/a&gt; — maintainer confirmation that a post-3.5 computed self-disposes rather than being stopped by its scope.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CrashReport" rel="noopener noreferrer"&gt;CrashReport — MDN&lt;/a&gt; — the out-of-band &lt;code&gt;crash&lt;/code&gt; report, delivered to a &lt;code&gt;Reporting-Endpoints&lt;/code&gt; target after the renderer is gone.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://eslint.org/docs/latest/rules/no-restricted-syntax" rel="noopener noreferrer"&gt;ESLint rule: no-restricted-syntax — ESLint docs&lt;/a&gt; — the selector-based fallback: it can ban a call, but matches one node and never a pair.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://eslint.vuejs.org/rules/no-watch-after-await.html" rel="noopener noreferrer"&gt;ESLint rule: vue/no-watch-after-await — eslint-plugin-vue&lt;/a&gt; — the essential-preset rule for asynchronously registered watchers.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://eslint-react.xyz/docs/rules/web-api-no-leaked-interval" rel="noopener noreferrer"&gt;ESLint rule: web-api-no-leaked-interval — @eslint-react&lt;/a&gt; — verifies &lt;code&gt;setInterval&lt;/code&gt;/&lt;code&gt;clearInterval&lt;/code&gt; pairing, but only inside &lt;code&gt;useEffect&lt;/code&gt;, so it never fires on &lt;code&gt;onMounted&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://stackinsight.dev/blog/memory-leak-empirical-study/" rel="noopener noreferrer"&gt;Frontend Memory Leaks: a 500-repository study — StackInsight, 2026&lt;/a&gt; — leak-category prevalence data across 500 public repos, with per-framework finding counts.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wicg.github.io/performance-measure-memory/" rel="noopener noreferrer"&gt;Measure Memory API — WICG draft&lt;/a&gt; — a Draft Community Group Report, explicitly not a W3C Standard nor on the standards track.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://facebook.github.io/memlab/" rel="noopener noreferrer"&gt;memlab: JavaScript memory leak detector — Meta&lt;/a&gt; — automates the snapshot-diff loop for deterministic CI checks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nuxt.com/docs/4.x/getting-started/state-management" rel="noopener noreferrer"&gt;Nuxt State Management: best practices — Nuxt docs&lt;/a&gt; — why a module-scope &lt;code&gt;ref&lt;/code&gt; leaks across SSR requests; the out-of-scope neighbour of this article.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Performance/measureUserAgentSpecificMemory" rel="noopener noreferrer"&gt;Performance.measureUserAgentSpecificMemory() — MDN&lt;/a&gt; — Chromium-only and experimental; rejects with &lt;code&gt;SecurityError&lt;/code&gt; unless the document is cross-origin isolated.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory" rel="noopener noreferrer"&gt;Performance.memory — MDN&lt;/a&gt; — deprecated and non-standard; named here only to rule it out as a fallback.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pinia.vuejs.org/core-concepts/actions.html" rel="noopener noreferrer"&gt;Pinia: Actions — Pinia docs&lt;/a&gt; — &lt;code&gt;$onAction&lt;/code&gt; binds to the component that registers it unless &lt;code&gt;true&lt;/code&gt; is passed as the second argument.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pinia.vuejs.org/core-concepts/state.html" rel="noopener noreferrer"&gt;Pinia: State — Pinia docs&lt;/a&gt; — &lt;code&gt;$subscribe&lt;/code&gt; binds to the component that registers it unless detached.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vuejs/rfcs/blob/master/active-rfcs/0041-reactivity-effect-scope.md" rel="noopener noreferrer"&gt;RFC 0041: Reactivity effectScope — vuejs/rfcs&lt;/a&gt; — design intent behind &lt;code&gt;effectScope&lt;/code&gt; and scope-bound disposal.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vitest.dev/api/vi.html" rel="noopener noreferrer"&gt;Vitest API: vi — Vitest docs&lt;/a&gt; — &lt;code&gt;vi.spyOn&lt;/code&gt; on a global, and the timer globals &lt;code&gt;useFakeTimers&lt;/code&gt; replaces.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vitest.dev/guide/environment" rel="noopener noreferrer"&gt;Vitest: Test Environment — Vitest docs&lt;/a&gt; — &lt;code&gt;node&lt;/code&gt; is the default; &lt;code&gt;happy-dom&lt;/code&gt; or &lt;code&gt;jsdom&lt;/code&gt; supplies the DOM that &lt;code&gt;mount&lt;/code&gt; needs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vuejs/core/blob/main/packages/reactivity/src/watch.ts" rel="noopener noreferrer"&gt;Vue core: reactivity/watch.ts — vuejs/core&lt;/a&gt; — watcher cleanups are registered as the effect's &lt;code&gt;onStop&lt;/code&gt; hook, so they also run when the watcher stops.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://devtools.vuejs.org/getting-started/features" rel="noopener noreferrer"&gt;Vue DevTools: Features — Vue DevTools docs&lt;/a&gt; — the documented panel list; no memory or retention panel among them.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://test-utils.vuejs.org/api/" rel="noopener noreferrer"&gt;Vue Test Utils: API reference — Vue Test Utils docs&lt;/a&gt; — &lt;code&gt;mount&lt;/code&gt;, and the root wrapper's &lt;code&gt;unmount()&lt;/code&gt; firing the component's &lt;code&gt;unmounted&lt;/code&gt; hook.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vuejs.org/guide/built-ins/keep-alive.html#lifecycle-of-cached-instance" rel="noopener noreferrer"&gt;Vue.js KeepAlive guide: lifecycle of a cached instance — Vue docs&lt;/a&gt; — a cached component is deactivated rather than unmounted; &lt;code&gt;onActivated&lt;/code&gt; / &lt;code&gt;onDeactivated&lt;/code&gt; are the hooks for that state.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vuejs.org/api/reactivity-advanced.html#onscopedispose" rel="noopener noreferrer"&gt;Vue.js Reactivity API: Advanced — Vue docs&lt;/a&gt; — &lt;code&gt;effectScope&lt;/code&gt;, &lt;code&gt;getCurrentScope&lt;/code&gt;, &lt;code&gt;onScopeDispose&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vuejs.org/guide/scaling-up/ssr.html#cross-request-state-pollution" rel="noopener noreferrer"&gt;Vue.js SSR guide: cross-request state pollution — Vue docs&lt;/a&gt; — singleton state reused across requests on a long-running server process.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vuejs.org/guide/essentials/watchers.html#stopping-a-watcher" rel="noopener noreferrer"&gt;Vue.js Watchers guide — Vue docs&lt;/a&gt; — auto-disposal of synchronous watchers, async-callback trap, &lt;code&gt;onWatcherCleanup&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vueuse.org/core/useEventListener/" rel="noopener noreferrer"&gt;VueUse: useEventListener — VueUse docs&lt;/a&gt; — listener registered on mount, removed automatically on unmount.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vueuse.org/shared/useIntervalFn/" rel="noopener noreferrer"&gt;VueUse: useIntervalFn — VueUse docs&lt;/a&gt; — timer composable signature and its &lt;code&gt;Pausable&lt;/code&gt; controls.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vueuse/vueuse/blob/main/packages/shared/useIntervalFn/index.ts" rel="noopener noreferrer"&gt;VueUse: useIntervalFn source — vueuse/vueuse&lt;/a&gt; — hands &lt;code&gt;pause&lt;/code&gt; to &lt;code&gt;tryOnScopeDispose&lt;/code&gt;, which is what clears the interval automatically.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap/set" rel="noopener noreferrer"&gt;WeakMap.prototype.set — MDN&lt;/a&gt; — WeakMap keys must be objects or non-registered symbols; anything else throws a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/getsentry/sentry-javascript/issues/5280" rel="noopener noreferrer"&gt;Why Sentry cannot detect page crashes — getsentry/sentry-javascript issue #5280&lt;/a&gt; — a maintainer's explanation that an OOM kill stops the SDK along with the page.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vue</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Cancelling API Requests in Vue 3: One Registry, Two Paths, and the Question That Orders Them</title>
      <dc:creator>Eve</dc:creator>
      <pubDate>Tue, 28 Jul 2026 12:03:00 +0000</pubDate>
      <link>https://dev.to/eveko/cancelling-api-requests-in-vue-3-one-registry-two-paths-and-the-question-that-orders-them-26h3</link>
      <guid>https://dev.to/eveko/cancelling-api-requests-in-vue-3-one-registry-two-paths-and-the-question-that-orders-them-26h3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://eveko.dev/articles/cancelling-api-requests-vue-3?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_campaign=cancelling-api-requests-vue-3" rel="noopener noreferrer"&gt;eveko.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Vue 3 exposes four things that look like four separate ways to cancel a stale request. The &lt;code&gt;onCleanup&lt;/code&gt; parameter that &lt;code&gt;watchEffect&lt;/code&gt; passes to its effect. The same parameter that &lt;code&gt;watch&lt;/code&gt; passes to its callback. The &lt;code&gt;onWatcherCleanup&lt;/code&gt; import shipped in &lt;a href="https://blog.vuejs.org/posts/vue-3-5" rel="noopener noreferrer"&gt;Vue 3.5&lt;/a&gt;. And the &lt;code&gt;AbortController&lt;/code&gt; that does the actual cancelling. They are not four ways — they are two registration paths over one cleanup registry, plus the single object that aborts the request. And one of those two paths silently stops working the instant an &lt;code&gt;await&lt;/code&gt; runs: no exception, no production warning, just a stale request that resolves on its own schedule and overwrites fresher data. The fix for the request that won't cancel is knowing which path survives the await, not reaching for a newer API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three costs of a request nobody cancelled
&lt;/h2&gt;

&lt;p&gt;Three different things break when a request outlives its relevance, and they are genuinely separate problems. Treating cancellation as a performance nicety dismisses the first one, which is the one that draws blood.&lt;/p&gt;

&lt;p&gt;That first cost is correctness. A search field is bound to a &lt;code&gt;watch&lt;/code&gt;; every keystroke fires a request. The user types &lt;code&gt;k&lt;/code&gt;, &lt;code&gt;ki&lt;/code&gt;, &lt;code&gt;kit&lt;/code&gt;, &lt;code&gt;kitt&lt;/code&gt;, &lt;code&gt;kitte&lt;/code&gt;, &lt;code&gt;kitten&lt;/code&gt;, &lt;code&gt;kittens&lt;/code&gt; — seven requests, seven round-trips, no guarantee they return in order. The request for &lt;code&gt;kit&lt;/code&gt; hits a slow replica and lands last. The watcher's callback runs with the &lt;code&gt;kit&lt;/code&gt; payload, overwrites the &lt;code&gt;kittens&lt;/code&gt; results already on screen, and the dashboard now shows results for a query the user finished typing two seconds ago. The URL still says &lt;code&gt;kittens&lt;/code&gt;. Nothing threw. The official docs name this exact race: "what if &lt;code&gt;id&lt;/code&gt; changes before the request completes? When the previous request completes, it will still fire the callback with an ID value that is already stale" (&lt;a href="https://vuejs.org/guide/essentials/watchers" rel="noopener noreferrer"&gt;Vue watchers guide&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The second cost is wasted work, and it scales with traffic. Every abandoned request still ran. The browser opened the connection, the server ran the query, the bytes crossed the wire. One stale search is free. A dashboard that re-fires on every filter change, parked on a second monitor all day, is a steady drip of work nobody will read: bandwidth on the client, CPU and connection-pool slots on the server.&lt;/p&gt;

&lt;p&gt;The third cost is resource lifetime, and it's the quietest. A request in flight pins memory until it settles: the promise, its closure, and the response-body buffer once headers arrive — all reachable, none collectable, until the fetch resolves or rejects.&lt;/p&gt;

&lt;p&gt;One clarification before the mechanism, because the search-as-you-type framing invites it: cancellation is not debounce. Debounce decides &lt;em&gt;whether&lt;/em&gt; to fire a request; cancellation decides what to do with the ones already in flight. They compose (debounce thins a storm of keystrokes to one request per pause, cancellation kills the previous request when the next one does fire), and a real search box usually wants both. Request deduplication (collapsing identical in-flight requests into one) is the third companion, and throttle (a cousin of debounce that a search box rarely needs) the fourth; dedup gets conflated with cancellation often enough that the library section below has to pull the two apart. The other place reactive fetching lives — &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; with an async &lt;code&gt;setup()&lt;/code&gt; — has its own cancellation story and stays out of scope here, and so does SSR: on the server the lazy &lt;code&gt;watch&lt;/code&gt; callbacks that drive these fetches never fire, so request cancellation is a client-side concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The patterns that lost to &lt;code&gt;AbortController&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Browser request cancellation has a short, tangled history, and the current advice rests on knowing which of the old patterns to stop reaching for. &lt;code&gt;XMLHttpRequest&lt;/code&gt; carried &lt;code&gt;.abort()&lt;/code&gt; from the start, but almost nobody writes against XHR directly anymore. Axios shipped its own &lt;code&gt;CancelToken&lt;/code&gt;, then &lt;a href="https://axios-http.com/docs/cancellation" rel="noopener noreferrer"&gt;deprecated it in 0.22&lt;/a&gt; in favor of &lt;code&gt;AbortController&lt;/code&gt;; code still carrying &lt;code&gt;CancelToken&lt;/code&gt; is the clearest sign a cancellation path predates the 0.22 deprecation (October 2021) and is overdue for migration. Then there's the pattern that never went through a deprecation because it was never really an API: the staleness flag.&lt;/p&gt;

&lt;p&gt;Plenty of hand-rolled search boxes still endorse the flag, and it's wrong in a way that matters. Watch the guard on the last line — it gates the &lt;em&gt;render&lt;/em&gt;, not the &lt;em&gt;request&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchTerm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;latest&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;latest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="c1"&gt;// stale responses get dropped here&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flag fixes correctness and nothing else. The stale request still ran to completion (the connection opened, the server did the work, the buffer filled), so two of the three costs survive. This article rejects the flag for exactly that reason. &lt;code&gt;AbortController&lt;/code&gt; addresses all three, because it cancels the request itself rather than ignoring the answer. The one line readers misjudge is the &lt;code&gt;catch&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchTerm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="c1"&gt;// aborting rejects the promise; that's expected, rethrow the rest&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aborting an in-flight fetch rejects its promise with an &lt;code&gt;AbortError&lt;/code&gt;, so the &lt;code&gt;catch&lt;/code&gt; has to let that specific error pass and rethrow everything else — skip it and every cancellation surfaces as an unhandled rejection. Where the rethrown error surfaces (an &lt;code&gt;error&lt;/code&gt; ref set in the &lt;code&gt;catch&lt;/code&gt;, or an &lt;code&gt;onErrorCaptured&lt;/code&gt; above) and how the loading state is shown are the component's concern, orthogonal to cancellation and left out of these examples. The pre-3.5 Nuxt recipe wired the same &lt;code&gt;AbortController&lt;/code&gt; into &lt;a href="https://nuxt.com/docs/api/composables/use-async-data" rel="noopener noreferrer"&gt;&lt;code&gt;useAsyncData&lt;/code&gt;&lt;/a&gt; and exposed a manual cancel function: same primitive, more plumbing.&lt;/p&gt;

&lt;p&gt;With the canceller settled (it's &lt;code&gt;AbortController&lt;/code&gt;, everywhere, now the de facto cancellation primitive), the only open question is where the &lt;code&gt;controller.abort()&lt;/code&gt; registration lands so it actually fires. That's where the four names come in, and where 3.5 added a second path with a sharp edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four names, two registration paths, one registry
&lt;/h2&gt;

&lt;p&gt;Here is the whole map before the zoom-in. The four names collapse into three roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The canceller.&lt;/strong&gt; &lt;code&gt;AbortController&lt;/code&gt; and its &lt;code&gt;signal&lt;/code&gt;. The only thing that actually stops a request. Settled above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The parameter path.&lt;/strong&gt; &lt;code&gt;watchEffect&lt;/code&gt; passes an &lt;code&gt;onCleanup&lt;/code&gt; function as the &lt;em&gt;first&lt;/em&gt; argument of its effect; &lt;code&gt;watch&lt;/code&gt; passes the identical function as the &lt;em&gt;third&lt;/em&gt; argument of its callback. Same function, same registry, two entry points — one per watcher API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The import path.&lt;/strong&gt; &lt;code&gt;onWatcherCleanup&lt;/code&gt;, imported from &lt;code&gt;vue&lt;/code&gt;, added in 3.5. It registers into the same registry as the parameter, but finds its watcher a different way — and that difference is the whole article.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signatures, from the &lt;a href="https://vuejs.org/api/reactivity-core" rel="noopener noreferrer"&gt;reactivity API reference&lt;/a&gt;, show the shape but not the catch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;watchEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OnCleanup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;WatchEffectOptions&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;WatchHandle&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OnCleanup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleanupFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;cleanupFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;failSilently&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both paths register the same kind of callback: something that runs right before the watcher re-runs, and again when the watcher is stopped. The &lt;code&gt;onCleanup&lt;/code&gt; parameter and &lt;code&gt;onWatcherCleanup&lt;/code&gt; are two doors into one room. The rest of this piece is about why one of the doors is sometimes locked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cleanup registry, keyed by the effect
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;packages/reactivity/src/watch.ts&lt;/code&gt;, the cleanup callbacks for every watcher live in one structure (&lt;a href="https://github.com/vuejs/core/blob/main/packages/reactivity/src/watch.ts" rel="noopener noreferrer"&gt;Vue core source&lt;/a&gt;). The key is the part to notice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanupMap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;WeakMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ReactiveEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WeakMap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;WeakMap&lt;/code&gt; keyed by the effect, holding an array of cleanup functions per watcher. Because the key is the effect itself, the whole entry is garbage-collected when the watcher is; the registry can't leak the cleanups it holds. Register a cleanup and it's pushed onto that watcher's array. When the watcher re-runs, the array drains first; the source comment is blunt about it: "cleanup before running cb again." When the watcher is disposed, the same array runs through the effect's &lt;code&gt;onStop&lt;/code&gt;. That's the entire lifecycle: run before next, run on stop.&lt;/p&gt;

&lt;p&gt;Here's the toy version — one &lt;code&gt;watchEffect&lt;/code&gt;, the cleanup wired through the first-argument parameter, no error handling or result use because the only thing on display is the wiring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;watchEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;watchEffect&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// load-bearing: register the abort the instant the controller exists&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/user/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time &lt;code&gt;userId&lt;/code&gt; changes, &lt;code&gt;watchEffect&lt;/code&gt; re-runs: it drains the previous cleanup (which aborts the prior request), then runs the effect again with a fresh controller. The &lt;code&gt;onCleanup&lt;/code&gt; parameter here is a closure. When &lt;code&gt;watchEffect&lt;/code&gt; invokes the effect, it hands in a function that already knows which watcher it belongs to, because it was created bound to that watcher. Hold onto that word — &lt;em&gt;bound&lt;/em&gt;. It's the difference between the two paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;onWatcherCleanup&lt;/code&gt; stops working after the first &lt;code&gt;await&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;onWatcherCleanup&lt;/code&gt; registers into that same array. It finds its watcher through a different mechanism, and that mechanism has an expiry. The docs admit the limit in a parenthetical: &lt;code&gt;onWatcherCleanup&lt;/code&gt; "can only be called during the synchronous execution of a &lt;code&gt;watchEffect&lt;/code&gt; effect function or &lt;code&gt;watch&lt;/code&gt; callback function (i.e. it cannot be called after an &lt;code&gt;await&lt;/code&gt; statement in an async function)" (&lt;a href="https://vuejs.org/api/reactivity-core" rel="noopener noreferrer"&gt;reactivity API reference&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The reason is one module-level variable. In &lt;a href="https://github.com/vuejs/core/blob/main/packages/reactivity/src/watch.ts" rel="noopener noreferrer"&gt;&lt;code&gt;watch.ts&lt;/code&gt;&lt;/a&gt;, &lt;code&gt;onWatcherCleanup&lt;/code&gt; defaults its owner to &lt;code&gt;activeWatcher&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;cleanupFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;failSilently&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactiveEffect&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;activeWatcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;activeWatcher&lt;/code&gt; is a single mutable binding (&lt;code&gt;let activeWatcher: ReactiveEffect | undefined = undefined&lt;/code&gt;) that Vue sets when a watcher enters its synchronous phase and clears the moment that phase ends. A call before the first &lt;code&gt;await&lt;/code&gt; sees the live watcher. A call after the &lt;code&gt;await&lt;/code&gt; sees &lt;code&gt;undefined&lt;/code&gt;, because the synchronous phase finished several microtasks ago. Watch where the registration sits relative to the &lt;code&gt;await&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onWatcherCleanup&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/user/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// activeWatcher is already undefined — registers nothing&lt;/span&gt;
  &lt;span class="nf"&gt;applyProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call registers nothing. With &lt;code&gt;failSilently&lt;/code&gt; at its default, Vue emits a warning ("onWatcherCleanup() was called when there was no active watcher to associate with", from &lt;a href="https://github.com/vuejs/core/blob/main/packages/reactivity/src/watch.ts" rel="noopener noreferrer"&gt;&lt;code&gt;watch.ts&lt;/code&gt;&lt;/a&gt;), and in production that warning is stripped, so the call is a pure no-op. The controller never enters the registry, the previous request is never aborted, and every cost from the first section is back on the table. This isn't hypothetical: the open issue requesting clearer docs and worked examples for this after-&lt;code&gt;await&lt;/code&gt; footgun (&lt;a href="https://github.com/vuejs/docs/issues/3222" rel="noopener noreferrer"&gt;vuejs/docs #3222&lt;/a&gt;) was opened in April 2025 and is still open, which means the runtime still won't throw and the docs note remains the only guardrail. Since even that dev-time warning vanishes in production, the one thing that still catches the bug is a test: spy on &lt;code&gt;abort()&lt;/code&gt; to assert the prior request is cancelled, or drive the out-of-order race and assert the fresher result isn't overwritten.&lt;/p&gt;

&lt;p&gt;The blunt repair is to register during the synchronous phase, before the first &lt;code&gt;await&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// still inside the sync phase — registers fine&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/user/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nf"&gt;applyProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Move one line up and &lt;code&gt;onWatcherCleanup&lt;/code&gt; behaves exactly as advertised.&lt;/p&gt;

&lt;p&gt;None of this means 3.5 was wrong to ship the global. The &lt;code&gt;onCleanup&lt;/code&gt; parameter carries a real ergonomic cost: it has to be threaded through the watcher's signature, and a cleanup that needs to be registered from inside a synchronous helper three calls deep can't reach the parameter unless every layer passes it down by hand. &lt;code&gt;onWatcherCleanup&lt;/code&gt; solves precisely that — it pulls the active watcher from module state, so any synchronous code running under the watcher can register cleanup with no parameter in scope. That's a real ergonomic gain: the cleanup reads beside the effect, with nothing bolted onto the callback's signature to carry it there. 3.5 shipped a different tool with a different constraint, and the constraint is invisible until an &lt;code&gt;await&lt;/code&gt; crosses it: &lt;code&gt;onWatcherCleanup&lt;/code&gt; is the cleaner call in synchronous code and a trap in asynchronous code, exactly the code doing the fetching it's most often demonstrated with.&lt;/p&gt;

&lt;p&gt;The parameter removes the ordering question from registration. Because it's the bound closure from the previous section, the docs are explicit that it "is bound to the watcher instance so it is not subject to the synchronous constraint of &lt;code&gt;onWatcherCleanup&lt;/code&gt;" (&lt;a href="https://vuejs.org/guide/essentials/watchers" rel="noopener noreferrer"&gt;Vue watchers guide&lt;/a&gt;) — it registers correctly whether it runs before or after an &lt;code&gt;await&lt;/code&gt;, because there's no &lt;code&gt;activeWatcher&lt;/code&gt; for it to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a library earns the dependency
&lt;/h2&gt;

&lt;p&gt;The strongest case against everything above is short: don't hand-roll cancellation at all. A data-fetching library already owns the registry, the signal, and the dedup, and a search box is the exact use case those libraries were built for. The counter insists the primitive is a waste of attention — and for the search box, it's mostly right.&lt;/p&gt;

&lt;p&gt;VueUse's &lt;code&gt;useFetch&lt;/code&gt; wraps an &lt;code&gt;AbortController&lt;/code&gt; and re-runs on reactive source changes. Two of its options cancel. &lt;code&gt;timeout&lt;/code&gt; aborts on a deadline; &lt;code&gt;refetch&lt;/code&gt; is the one a search box wants — set it &lt;code&gt;true&lt;/code&gt; and the previous request aborts automatically whenever the URL ref changes (&lt;a href="https://github.com/vueuse/vueuse/blob/main/packages/core/useFetch/index.ts" rel="noopener noreferrer"&gt;VueUse &lt;code&gt;useFetch&lt;/code&gt; source&lt;/a&gt;). The signal threading from the earlier examples disappears, and the library does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useFetch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vueuse/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;searchTerm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;refetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// aborts the in-flight request when `url` changes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's genuinely less code than the hand-rolled watcher, and for cancellation it is precisely as much as the wrapper buys. &lt;code&gt;useFetch&lt;/code&gt; does plenty else — interceptors, &lt;code&gt;timeout&lt;/code&gt;, typed body helpers — but nothing resembling a query cache: no key, no shared in-flight registry, no module-level cache. Each call owns its own refs, so two components requesting the same URL fire two requests. What reads like deduplication is supersede. &lt;code&gt;execute()&lt;/code&gt; opens by calling its own &lt;code&gt;abort()&lt;/code&gt; before firing the next request (&lt;a href="https://github.com/vueuse/vueuse/blob/main/packages/core/useFetch/index.ts" rel="noopener noreferrer"&gt;VueUse &lt;code&gt;useFetch&lt;/code&gt; source&lt;/a&gt;), and killing the previous request is not collapsing two identical ones. Nothing in the documented option list claims otherwise. &lt;code&gt;useFetch&lt;/code&gt; also exposes &lt;code&gt;abort&lt;/code&gt;, &lt;code&gt;canAbort&lt;/code&gt;, and &lt;code&gt;aborted&lt;/code&gt; for wiring a manual Stop button — a different interaction than search-as-you-type, so it stays a mention rather than a contrived demo here.&lt;/p&gt;

&lt;p&gt;One gap is worth naming, because the wrapper doesn't advertise it. &lt;code&gt;useFetch&lt;/code&gt; aborts at the start of the next &lt;code&gt;execute()&lt;/code&gt;: on URL change, on &lt;code&gt;timeout&lt;/code&gt;, or when &lt;code&gt;abort()&lt;/code&gt; is called by hand. What it never does is register a scope-disposal hook. There is no &lt;code&gt;onScopeDispose&lt;/code&gt; and no &lt;code&gt;onUnmounted&lt;/code&gt; anywhere in &lt;a href="https://github.com/vueuse/vueuse/blob/main/packages/core/useFetch/index.ts" rel="noopener noreferrer"&gt;the source&lt;/a&gt;. Unmount the component mid-request and the fetch runs to completion, which is precisely the third cost from the top of this article. The hand-rolled &lt;code&gt;onCleanup&lt;/code&gt; version handles it, because the watcher's cleanup runs when the scope stops. Closing it takes one line, because the wrapper hands back the &lt;code&gt;abort&lt;/code&gt; it never calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onScopeDispose&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;abort&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;refetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;onScopeDispose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// the half `refetch` doesn't cover: abort the in-flight request when the scope stops&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One consequence before wiring it: the abort rejects the request, so &lt;code&gt;onFetchError&lt;/code&gt; fires with an &lt;code&gt;AbortError&lt;/code&gt; on every unmount that interrupts a fetch. Gate that handler on &lt;code&gt;error.name !== 'AbortError'&lt;/code&gt; if it shows a toast or reports to Sentry. None of this is a reason to avoid the library. It's a reason to know which half of the lifecycle you delegated.&lt;/p&gt;

&lt;p&gt;TanStack Query (via &lt;code&gt;@tanstack/vue-query&lt;/code&gt;) argues the same position from the other end: it hands an &lt;code&gt;AbortSignal&lt;/code&gt; to every query function and treats that signal as the universal cancellation contract. The catch is the default. TanStack does not cancel on change or unmount unless the query function actually forwards the signal it's handed (&lt;a href="https://tanstack.com/query/latest/docs/framework/vue/guides/query-cancellation" rel="noopener noreferrer"&gt;TanStack Query cancellation&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useQuery&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/vue-query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;searchTerm&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;queryFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;searchTerm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="c1"&gt;// forward `signal` or nothing cancels&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "the library makes it automatic" is half true: automatic that the signal exists, manual that it's used. That's the seam in the counter. The primitive's defenders concede the search box to the library — when the app already depends on one, or when the use case wants the cache, dedup, and retry machinery, reaching for TanStack is the right call. What &lt;code&gt;useFetch&lt;/code&gt; buys for this problem is the abort bookkeeping, and there's no cache machinery behind it. The line holds where the alternative is adding a data-fetching dependency to abort a single fetch, or where the code needs control the wrapper doesn't expose: the watcher's &lt;code&gt;flush&lt;/code&gt; timing, a non-&lt;code&gt;fetch&lt;/code&gt; cancellable, cleanup sequenced against other effects. The primitive is small. Owning it is not the sin the counter implies, provided the registration lands where it fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision rule
&lt;/h2&gt;

&lt;p&gt;Every choice above reduces to one question: is the &lt;code&gt;controller.abort()&lt;/code&gt; registration still running inside the watcher's synchronous phase? If yes, any path works — pick on ergonomics. Once the registration has crossed an &lt;code&gt;await&lt;/code&gt;, only the bound parameter survives. Everything else the docs dwell on — &lt;code&gt;flush&lt;/code&gt; timing, the &lt;code&gt;failSilently&lt;/code&gt; flag — the rule dismisses as secondary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best practice:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In general:&lt;/strong&gt; &lt;code&gt;AbortController&lt;/code&gt; is the canceller; the only real decision is where the &lt;code&gt;abort()&lt;/code&gt; registration runs — and it has to run before the first &lt;code&gt;await&lt;/code&gt;, or use the path that doesn't care.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Synchronous registration (no &lt;code&gt;await&lt;/code&gt; before it)&lt;/strong&gt; — reach for &lt;code&gt;onWatcherCleanup&lt;/code&gt;; it's the cleanest call and threads no parameter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="c1"&gt;// pre-3.5 plumbing: a separate ref you abort and re-create by hand&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/user/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;onWatcherCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// co-located, sync phase&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/user/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Async callback — the &lt;code&gt;onCleanup&lt;/code&gt; parameter, registered before the first &lt;code&gt;await&lt;/code&gt;.&lt;/strong&gt; An &lt;code&gt;await&lt;/code&gt; is in the picture, so the &lt;code&gt;onCleanup&lt;/code&gt; parameter is the default over &lt;code&gt;onWatcherCleanup&lt;/code&gt;: bound to the watcher instance, it can't be silently stranded when a registration drifts past the &lt;code&gt;await&lt;/code&gt; the way the global is. That robustness is separate from the fix. The fix is position: the two snippets below run the identical parameter and signature, and only the registration line moves. Register it above the fetch and the abort is armed while the request is in flight. Drop it below, and the parameter still registers (surviving the &lt;code&gt;await&lt;/code&gt; is what it's for), but too late — the fetch has already resolved, and an abort on a settled controller cancels nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Avoid:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="nf"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// registers fine — but the fetch already resolved, so there's nothing left to abort&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;onCleanup&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="c1"&gt;// registered before the await, so the next change can abort this fetch mid-flight&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No appetite to own the registry&lt;/strong&gt; — reach for a library; &lt;code&gt;useFetch({ refetch: true })&lt;/code&gt;, or a TanStack query that forwards &lt;code&gt;signal&lt;/code&gt;, hides the &lt;code&gt;AbortController&lt;/code&gt; bookkeeping for you. They don't cover the same ground: TanStack aborts when the query goes inactive, &lt;code&gt;useFetch&lt;/code&gt; only at the start of the next request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prefer:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;abort&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;refetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nf"&gt;onScopeDispose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// abort-on-change is built in; abort-on-unmount is not&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three cases are three distinct fixes: co-locate in synchronous code, thread the parameter across async, or delegate the whole registry to a library that speaks &lt;code&gt;AbortSignal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The four names collapse to two ways into one registry, plus the object that does the aborting; the registry cares about one thing only: whether the registration call ran while the watcher was still executing synchronously. &lt;code&gt;onWatcherCleanup&lt;/code&gt; reads that state from a module variable that's already been cleared by the time an awaited fetch resolves; the &lt;code&gt;onCleanup&lt;/code&gt; parameter carries its watcher with it and never has to look. Reach for the global in synchronous code, and the parameter the instant an &lt;code&gt;await&lt;/code&gt; enters the picture. The bug was never the request that wouldn't cancel. It was the cleanup registered to a watcher that had already left the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nuxt.com/docs/api/composables/use-async-data" rel="noopener noreferrer"&gt;Nuxt docs — useAsyncData&lt;/a&gt; — the &lt;code&gt;useAsyncData&lt;/code&gt; composable the pre-3.5 Nuxt cancel recipe wraps with an &lt;code&gt;AbortController&lt;/code&gt; signal.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://tanstack.com/query/latest/docs/framework/vue/guides/query-cancellation" rel="noopener noreferrer"&gt;TanStack Query — Query Cancellation&lt;/a&gt; — &lt;code&gt;AbortSignal&lt;/code&gt;-as-contract design and the opt-in default (no cancel unless the signal is forwarded).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.vuejs.org/posts/vue-3-5" rel="noopener noreferrer"&gt;Vue 3.5 release announcement (Vue team, September 2024)&lt;/a&gt; — introduction of &lt;code&gt;onWatcherCleanup&lt;/code&gt; as a globally imported API.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vuejs/core/blob/main/packages/reactivity/src/watch.ts" rel="noopener noreferrer"&gt;Vue core source — packages/reactivity/src/watch.ts&lt;/a&gt; — &lt;code&gt;cleanupMap&lt;/code&gt;, &lt;code&gt;activeWatcher&lt;/code&gt;, the &lt;code&gt;onWatcherCleanup&lt;/code&gt; signature, and the no-active-watcher warning.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vuejs.org/api/reactivity-core" rel="noopener noreferrer"&gt;Vue docs — Reactivity API: Core&lt;/a&gt; — &lt;code&gt;watchEffect&lt;/code&gt; / &lt;code&gt;onWatcherCleanup&lt;/code&gt; signatures and the synchronous-execution constraint.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vuejs.org/guide/essentials/watchers" rel="noopener noreferrer"&gt;Vue docs — Watchers guide&lt;/a&gt; — the stale-request rationale and the &lt;code&gt;onCleanup&lt;/code&gt;-parameter instance-binding note.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vuejs/docs/issues/3222" rel="noopener noreferrer"&gt;vuejs/docs #3222 — onWatcherCleanup in async contexts&lt;/a&gt; — open issue documenting the after-&lt;code&gt;await&lt;/code&gt; footgun.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vueuse.org/core/useFetch/" rel="noopener noreferrer"&gt;VueUse — useFetch&lt;/a&gt; — the &lt;code&gt;abort&lt;/code&gt; / &lt;code&gt;canAbort&lt;/code&gt; / &lt;code&gt;aborted&lt;/code&gt; surface, the &lt;code&gt;timeout&lt;/code&gt; cancellation option, and the documented option list, which has no cache or dedup entry.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/vueuse/vueuse/blob/main/packages/core/useFetch/index.ts" rel="noopener noreferrer"&gt;VueUse — useFetch source&lt;/a&gt; — &lt;code&gt;execute()&lt;/code&gt; calls &lt;code&gt;abort()&lt;/code&gt; before each run (the auto-cancel on URL change), the absence of any module-level cache or in-flight registry, and the absence of any scope-disposal hook.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://axios-http.com/docs/cancellation" rel="noopener noreferrer"&gt;Axios — Cancellation&lt;/a&gt; — &lt;code&gt;CancelToken&lt;/code&gt; deprecated in favor of &lt;code&gt;AbortController&lt;/code&gt; (added in v0.22.0, October 2021).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vue</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
