[{"data":1,"prerenderedAt":3156},["ShallowReactive",2],{"article-vue-3-memory-leaks":3},{"id":4,"title":5,"body":6,"date":3141,"description":3142,"extension":3143,"image":3144,"meta":3145,"navigation":429,"path":3146,"readingTime":3147,"seo":3148,"stem":3149,"tags":3150,"__hash__":3155},"articles\u002Farticles\u002Fvue-3-memory-leaks.md","Vue 3 Cleans Up After Itself — Until It Can't",{"type":7,"value":8,"toc":3128},"minimark",[9,34,41,54,73,78,85,108,121,124,162,178,182,190,250,261,342,345,349,358,361,474,570,585,595,652,727,757,761,764,790,846,927,952,971,977,1058,1196,1199,1203,1206,1209,1256,1327,1337,1408,1501,1534,1572,1617,1654,1657,1661,1680,1709,1775,1848,1860,1932,2035,2039,2042,2049,2086,2129,2142,2146,2167,2179,2188,2366,2413,2474,2478,2493,2497,2500,2700,2706,2738,2804,2807,2811,3124],[10,11,12,13,17,18,21,22,25,26,33],"p",{},"Vue 3 disposes the reactive effects it creates. A ",[14,15,16],"code",{},"watch"," or a ",[14,19,20],{},"watchEffect"," declared in ",[14,23,24],{},"\u003Cscript setup>"," is bound to the component instance and torn down the moment that component unmounts — ",[27,28,32],"a",{"href":29,"rel":30},"https:\u002F\u002Fvuejs.org\u002Fguide\u002Fessentials\u002Fwatchers.html#stopping-a-watcher",[31],"nofollow","no cleanup code required",". 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.",[10,35,36,37,40],{},"A user opens the reports view, goes back to the list, and opens it again. Each visit starts a ",[14,38,39],{},"setInterval"," 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.",[10,42,43,44,49,50,53],{},"Both failures trace to the same root, and it isn't Vue. A 2026 static-analysis pass across 500 public repositories (",[27,45,48],{"href":46,"rel":47},"https:\u002F\u002Fstackinsight.dev\u002Fblog\u002Fmemory-leak-empirical-study\u002F",[31],"the StackInsight study",") 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 ",[14,51,52],{},"setup()"," 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.",[10,55,56,57,62,63,68,69,72],{},"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 ",[27,58,61],{"href":59,"rel":60},"https:\u002F\u002Fvuejs.org\u002Fguide\u002Fscaling-up\u002Fssr.html#cross-request-state-pollution",[31],"cross-request state pollution",", and ",[27,64,67],{"href":65,"rel":66},"https:\u002F\u002Fnuxt.com\u002Fdocs\u002F4.x\u002Fgetting-started\u002Fstate-management",[31],"Nuxt's state-management docs"," indict the module-scope ",[14,70,71],{},"ref"," 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.",[74,75,77],"h2",{"id":76},"vue-already-cleans-up-the-part-everyone-worries-about","Vue already cleans up the part everyone worries about",[10,79,80,81],{},"The mental model worth carrying is one sentence: ",[82,83,84],"strong",{},"Vue disposes the effects it owns, and owns nothing else.",[10,86,87,88,90,91,93,94,96,97,101,102,104,105,107],{},"Every component's ",[14,89,52],{}," 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 ",[14,92,52],{}," or ",[14,95,24],{}," 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 ",[27,98,100],{"href":29,"rel":99},[31],"Vue watchers guide","). A ",[14,103,16],{}," that fires on every store mutation, a ",[14,106,20],{}," that re-runs on every keystroke — both vanish cleanly on unmount, because Vue was holding the handle the whole time.",[10,109,110,111,114,115,120],{},"A ",[14,112,113],{},"computed"," 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 ",[27,116,119],{"href":117,"rel":118},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fissues\u002F11886",[31],"vuejs\u002Fcore#11886","). Either way, nothing is left for you to stop.",[10,122,123],{},"So the reactivity system is not the threat. The threat is everything Vue never saw you create. Three categories cover almost all of it:",[125,126,127,150,156],"ul",{},[128,129,130,133,134,136,137,136,140,136,143,136,146,149],"li",{},[82,131,132],{},"Manual browser APIs"," — ",[14,135,39],{},", ",[14,138,139],{},"addEventListener",[14,141,142],{},"requestAnimationFrame",[14,144,145],{},"IntersectionObserver",[14,147,148],{},"WebSocket",". Vue doesn't wrap these. It doesn't know they exist.",[128,151,152,155],{},[82,153,154],{},"Third-party library instances"," — a chart, a map, a rich-text editor. Each holds its own canvases, listeners, and buffers.",[128,157,158,161],{},[82,159,160],{},"References handed to something longer-lived"," — a module-scoped array, a global event bus, a Pinia store that keeps pushing component data and never lets go.",[10,163,164,165,136,168,136,171,136,174,177],{},"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: ",[14,166,167],{},"onUnmounted",[14,169,170],{},"onScopeDispose",[14,172,173],{},"effectScope",[14,175,176],{},"onWatcherCleanup",". The principle is old. The tools are new, and most of this article is about using them.",[74,179,181],{"id":180},"where-the-leaks-actually-live","Where the leaks actually live",[10,183,184,185,189],{},"The ",[27,186,188],{"href":46,"rel":187},[31],"StackInsight study"," ranked all 55,864 of its flagged leak sites by category. These are the top five of nine, counted across the full React\u002FVue\u002FAngular corpus; the shape of the ranking is the whole argument:",[191,192,193,206],"table",{},[194,195,196],"thead",{},[197,198,199,203],"tr",{},[200,201,202],"th",{},"Category",[200,204,205],{},"Share of leak sites",[207,208,209,218,226,234,242],"tbody",{},[197,210,211,215],{},[212,213,214],"td",{},"Missing timer cleanup",[212,216,217],{},"43.9%",[197,219,220,223],{},[212,221,222],{},"Missing event listener removal",[212,224,225],{},"19.0%",[197,227,228,231],{},[212,229,230],{},"Missing subscription cleanup",[212,232,233],{},"13.9%",[197,235,236,239],{},[212,237,238],{},"Missing effect cleanup",[212,240,241],{},"9.3%",[197,243,244,247],{},[212,245,246],{},"Missing watch stop handle",[212,248,249],{},"7.1%",[10,251,252,253,255,256,260],{},"One row needs unpacking before the shape reads correctly. ",[14,254,246],{}," 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 ",[27,257,259],{"href":46,"rel":258},[31],"study"," concedes the skew plainly: the sample was \"weighted toward React\".",[10,262,263,264,266,267,269,270,273,274,277,278,285,286,288,289,292,293,295,296,307,308,310,311,313,314,317,318,321,322,329,330,332,333,335,336,338,339,341],{},"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 ",[14,265,173],{}," API and still writes ",[14,268,39],{}," without ",[14,271,272],{},"clearInterval"," has optimized the quarter and shipped the rest. The tooling asymmetry matches: ",[14,275,276],{},"eslint-plugin-vue"," ships ",[27,279,282],{"href":280,"rel":281},"https:\u002F\u002Feslint.vuejs.org\u002Frules\u002Fno-watch-after-await.html",[31],[14,283,284],{},"vue\u002Fno-watch-after-await"," in its essential preset, which reports a ",[14,287,16],{}," registered after an ",[14,290,291],{},"await",", and ships nothing at all for a ",[14,294,39],{}," that never gets cleared. Closing that gap is a house rule, not a plugin install: ",[27,297,300,303,304],{"href":298,"rel":299},"https:\u002F\u002Feslint-react.xyz\u002Fdocs\u002Frules\u002Fweb-api-no-leaked-interval",[31],[14,301,302],{},"@eslint-react","'s ",[14,305,306],{},"web-api-no-leaked-interval"," does check that a ",[14,309,39],{}," is paired with a ",[14,312,272],{},", but reports only inside ",[14,315,316],{},"useEffect"," callbacks and stays silent in ",[14,319,320],{},"onMounted",". What a Vue team can enforce is a ",[27,323,326],{"href":324,"rel":325},"https:\u002F\u002Feslint.org\u002Fdocs\u002Flatest\u002Frules\u002Fno-restricted-syntax",[31],[14,327,328],{},"no-restricted-syntax"," selector catching the shape that is unfixable rather than merely unfixed: a ",[14,331,39],{}," whose return value is discarded, leaving no id for any ",[14,334,272],{}," to take. That bans one call; it never proves a teardown, because a selector cannot tie a ",[14,337,272],{}," to the id a particular ",[14,340,39],{}," returned.",[10,343,344],{},"So the rest of this is organized by what Vue can't see, roughly in order of how often it bites.",[74,346,348],{"id":347},"manual-browser-resources-timers-listeners-subscriptions-observers","Manual browser resources: timers, listeners, subscriptions, observers",[10,350,351,352,354,355,357],{},"These share one fix: whatever you start in ",[14,353,320],{},", stop in ",[14,356,167],{},". The interesting part is the ways the stop quietly fails to happen.",[10,359,360],{},"Start with listeners, because the failure has a sharp edge most people hit once. Watch the handler reference across the two calls.",[362,363,364],"avoid",{},[365,366,371],"pre",{"className":367,"code":368,"language":369,"meta":370,"style":370},"language-ts shiki shiki-themes github-light github-dark","onMounted(() => {\n  window.addEventListener('resize', () => layout(window.innerWidth))\n})\n\nonUnmounted(() => {\n  \u002F\u002F a second arrow — a different function, so this removes nothing\n  window.removeEventListener('resize', () => layout(window.innerWidth))\n})\n","ts","",[14,372,373,392,418,424,431,442,449,469],{"__ignoreMap":370},[374,375,378,381,385,389],"span",{"class":376,"line":377},"line",1,[374,379,320],{"class":380},"sScJk",[374,382,384],{"class":383},"sVt8B","(() ",[374,386,388],{"class":387},"szBVR","=>",[374,390,391],{"class":383}," {\n",[374,393,395,398,400,403,407,410,412,415],{"class":376,"line":394},2,[374,396,397],{"class":383},"  window.",[374,399,139],{"class":380},[374,401,402],{"class":383},"(",[374,404,406],{"class":405},"sZZnC","'resize'",[374,408,409],{"class":383},", () ",[374,411,388],{"class":387},[374,413,414],{"class":380}," layout",[374,416,417],{"class":383},"(window.innerWidth))\n",[374,419,421],{"class":376,"line":420},3,[374,422,423],{"class":383},"})\n",[374,425,427],{"class":376,"line":426},4,[374,428,430],{"emptyLinePlaceholder":429},true,"\n",[374,432,434,436,438,440],{"class":376,"line":433},5,[374,435,167],{"class":380},[374,437,384],{"class":383},[374,439,388],{"class":387},[374,441,391],{"class":383},[374,443,445],{"class":376,"line":444},6,[374,446,448],{"class":447},"sJ8bj","  \u002F\u002F a second arrow — a different function, so this removes nothing\n",[374,450,452,454,457,459,461,463,465,467],{"class":376,"line":451},7,[374,453,397],{"class":383},[374,455,456],{"class":380},"removeEventListener",[374,458,402],{"class":383},[374,460,406],{"class":405},[374,462,409],{"class":383},[374,464,388],{"class":387},[374,466,414],{"class":380},[374,468,417],{"class":383},[374,470,472],{"class":376,"line":471},8,[374,473,423],{"class":383},[475,476,477],"prefer",{},[365,478,480],{"className":367,"code":479,"language":369,"meta":370,"style":370},"function onResize() {\n  layout(window.innerWidth)\n}\n\nonMounted(() => {\n  window.addEventListener('resize', onResize)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('resize', onResize)\n})\n",[14,481,482,493,501,506,510,520,533,537,541,552,565],{"__ignoreMap":370},[374,483,484,487,490],{"class":376,"line":377},[374,485,486],{"class":387},"function",[374,488,489],{"class":380}," onResize",[374,491,492],{"class":383},"() {\n",[374,494,495,498],{"class":376,"line":394},[374,496,497],{"class":380},"  layout",[374,499,500],{"class":383},"(window.innerWidth)\n",[374,502,503],{"class":376,"line":420},[374,504,505],{"class":383},"}\n",[374,507,508],{"class":376,"line":426},[374,509,430],{"emptyLinePlaceholder":429},[374,511,512,514,516,518],{"class":376,"line":433},[374,513,320],{"class":380},[374,515,384],{"class":383},[374,517,388],{"class":387},[374,519,391],{"class":383},[374,521,522,524,526,528,530],{"class":376,"line":444},[374,523,397],{"class":383},[374,525,139],{"class":380},[374,527,402],{"class":383},[374,529,406],{"class":405},[374,531,532],{"class":383},", onResize)\n",[374,534,535],{"class":376,"line":451},[374,536,423],{"class":383},[374,538,539],{"class":376,"line":471},[374,540,430],{"emptyLinePlaceholder":429},[374,542,544,546,548,550],{"class":376,"line":543},9,[374,545,167],{"class":380},[374,547,384],{"class":383},[374,549,388],{"class":387},[374,551,391],{"class":383},[374,553,555,557,559,561,563],{"class":376,"line":554},10,[374,556,397],{"class":383},[374,558,456],{"class":380},[374,560,402],{"class":383},[374,562,406],{"class":405},[374,564,532],{"class":383},[374,566,568],{"class":376,"line":567},11,[374,569,423],{"class":383},[10,571,184,572,574,575,577,578,581,582,584],{},[14,573,362],{}," version is unfixable, not just unfixed. ",[14,576,456],{}," 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 ",[14,579,580],{},"layout"," 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 ",[14,583,580],{}," a dozen times, eleven of them on behalf of components that no longer exist.",[10,586,587,588,590,591,594],{},"Observers and sockets are the same story with a different verb. An ",[14,589,145],{}," watching a sentinel for infinite scroll keeps its target (and the component scope around it) alive until you ",[14,592,593],{},"disconnect()",".",[362,596,597],{},[365,598,600],{"className":367,"code":599,"language":369,"meta":370,"style":370},"const observer = new IntersectionObserver(onIntersect)\n\nonMounted(() => {\n  observer.observe(sentinel.value)\n})\n",[14,601,602,623,627,637,648],{"__ignoreMap":370},[374,603,604,607,611,614,617,620],{"class":376,"line":377},[374,605,606],{"class":387},"const",[374,608,610],{"class":609},"sj4cs"," observer",[374,612,613],{"class":387}," =",[374,615,616],{"class":387}," new",[374,618,619],{"class":380}," IntersectionObserver",[374,621,622],{"class":383},"(onIntersect)\n",[374,624,625],{"class":376,"line":394},[374,626,430],{"emptyLinePlaceholder":429},[374,628,629,631,633,635],{"class":376,"line":420},[374,630,320],{"class":380},[374,632,384],{"class":383},[374,634,388],{"class":387},[374,636,391],{"class":383},[374,638,639,642,645],{"class":376,"line":426},[374,640,641],{"class":383},"  observer.",[374,643,644],{"class":380},"observe",[374,646,647],{"class":383},"(sentinel.value)\n",[374,649,650],{"class":376,"line":433},[374,651,423],{"class":383},[475,653,654],{},[365,655,657],{"className":367,"code":656,"language":369,"meta":370,"style":370},"const observer = new IntersectionObserver(onIntersect)\n\nonMounted(() => {\n  observer.observe(sentinel.value)\n})\n\nonUnmounted(() => {\n  observer.disconnect()\n})\n",[14,658,659,673,677,687,695,699,703,713,723],{"__ignoreMap":370},[374,660,661,663,665,667,669,671],{"class":376,"line":377},[374,662,606],{"class":387},[374,664,610],{"class":609},[374,666,613],{"class":387},[374,668,616],{"class":387},[374,670,619],{"class":380},[374,672,622],{"class":383},[374,674,675],{"class":376,"line":394},[374,676,430],{"emptyLinePlaceholder":429},[374,678,679,681,683,685],{"class":376,"line":420},[374,680,320],{"class":380},[374,682,384],{"class":383},[374,684,388],{"class":387},[374,686,391],{"class":383},[374,688,689,691,693],{"class":376,"line":426},[374,690,641],{"class":383},[374,692,644],{"class":380},[374,694,647],{"class":383},[374,696,697],{"class":376,"line":433},[374,698,423],{"class":383},[374,700,701],{"class":376,"line":444},[374,702,430],{"emptyLinePlaceholder":429},[374,704,705,707,709,711],{"class":376,"line":451},[374,706,167],{"class":380},[374,708,384],{"class":383},[374,710,388],{"class":387},[374,712,391],{"class":383},[374,714,715,717,720],{"class":376,"line":471},[374,716,641],{"class":383},[374,718,719],{"class":380},"disconnect",[374,721,722],{"class":383},"()\n",[374,724,725],{"class":376,"line":543},[374,726,423],{"class":383},[10,728,110,729,731,732,735,736,731,739,741,742,731,745,741,747,749,750,753,754,756],{},[14,730,148],{}," wants ",[14,733,734],{},"close()",", an ",[14,737,738],{},"EventSource",[14,740,734],{},", a ",[14,743,744],{},"ResizeObserver",[14,746,593],{},[14,748,142],{}," loop wants ",[14,751,752],{},"cancelAnimationFrame",". 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 ",[14,755,272],{},", and the trap is forgetting that the interval's callback keeps its entire closure alive between ticks.",[74,758,760],{"id":759},"the-two-watcher-leaks","The two watcher leaks",[10,762,763],{},"Watchers leak in two distinct ways, and conflating them is why the advice around them is muddled. Only one is the reactivity system's.",[10,765,766,767,771,772,774,775,780,781,789],{},"The first leak isn't the watcher — it's what the watcher ",[768,769,770],"em",{},"starts",". 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 ",[14,773,176],{}," for exactly this: a teardown that runs when the watcher is invalidated and about to re-run (per the Vue docs on ",[27,776,779],{"href":777,"rel":778},"https:\u002F\u002Fvuejs.org\u002Fguide\u002Fessentials\u002Fwatchers.html#side-effect-cleanup",[31],"side effect cleanup","). It fires on the way out, too. Vue registers the cleanup as the effect's ",[27,782,785,788],{"href":783,"rel":784},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Fcore\u002Fblob\u002Fmain\u002Fpackages\u002Freactivity\u002Fsrc\u002Fwatch.ts",[31],[14,786,787],{},"onStop"," hook",", so unmounting the component closes the last socket as well as every one superseded mid-flight.",[362,791,792],{},[365,793,795],{"className":367,"code":794,"language":369,"meta":370,"style":370},"watch(roomId, (id) => {\n  const socket = openSocket(id)\n  socket.onMessage(handleMessage)\n})\n",[14,796,797,815,831,842],{"__ignoreMap":370},[374,798,799,801,804,808,811,813],{"class":376,"line":377},[374,800,16],{"class":380},[374,802,803],{"class":383},"(roomId, (",[374,805,807],{"class":806},"s4XuR","id",[374,809,810],{"class":383},") ",[374,812,388],{"class":387},[374,814,391],{"class":383},[374,816,817,820,823,825,828],{"class":376,"line":394},[374,818,819],{"class":387},"  const",[374,821,822],{"class":609}," socket",[374,824,613],{"class":387},[374,826,827],{"class":380}," openSocket",[374,829,830],{"class":383},"(id)\n",[374,832,833,836,839],{"class":376,"line":420},[374,834,835],{"class":383},"  socket.",[374,837,838],{"class":380},"onMessage",[374,840,841],{"class":383},"(handleMessage)\n",[374,843,844],{"class":376,"line":426},[374,845,423],{"class":383},[475,847,848],{},[365,849,851],{"className":367,"code":850,"language":369,"meta":370,"style":370},"import { watch, onWatcherCleanup } from 'vue'\n\nwatch(roomId, (id) => {\n  const socket = openSocket(id)\n  socket.onMessage(handleMessage)\n  onWatcherCleanup(() => socket.close())\n})\n",[14,852,853,867,871,885,897,905,923],{"__ignoreMap":370},[374,854,855,858,861,864],{"class":376,"line":377},[374,856,857],{"class":387},"import",[374,859,860],{"class":383}," { watch, onWatcherCleanup } ",[374,862,863],{"class":387},"from",[374,865,866],{"class":405}," 'vue'\n",[374,868,869],{"class":376,"line":394},[374,870,430],{"emptyLinePlaceholder":429},[374,872,873,875,877,879,881,883],{"class":376,"line":420},[374,874,16],{"class":380},[374,876,803],{"class":383},[374,878,807],{"class":806},[374,880,810],{"class":383},[374,882,388],{"class":387},[374,884,391],{"class":383},[374,886,887,889,891,893,895],{"class":376,"line":426},[374,888,819],{"class":387},[374,890,822],{"class":609},[374,892,613],{"class":387},[374,894,827],{"class":380},[374,896,830],{"class":383},[374,898,899,901,903],{"class":376,"line":433},[374,900,835],{"class":383},[374,902,838],{"class":380},[374,904,841],{"class":383},[374,906,907,910,912,914,917,920],{"class":376,"line":444},[374,908,909],{"class":380},"  onWatcherCleanup",[374,911,384],{"class":383},[374,913,388],{"class":387},[374,915,916],{"class":383}," socket.",[374,918,919],{"class":380},"close",[374,921,922],{"class":383},"())\n",[374,924,925],{"class":376,"line":451},[374,926,423],{"class":383},[10,928,929,931,932,934,935,938,939,942,943,946,947,951],{},[14,930,176],{}," lands in Vue 3.5+, and it has to be called during the watcher's synchronous execution — never after an ",[14,933,291],{},". On older versions, and past that constraint, the third callback argument (",[14,936,937],{},"onCleanup",") binds to the watcher instance instead. When the side effect is a ",[14,940,941],{},"fetch",", the cleanup is an ",[14,944,945],{},"AbortController",", and the same cleanup seam carries it — the deeper mechanics of racing requests get their own treatment in the piece on ",[27,948,950],{"href":949},"\u002Farticles\u002Fcancelling-api-requests-vue-3","cancelling API requests in Vue 3",", but nothing in this section waits on it. The leak angle is the simpler half: started, so stop it.",[10,953,954,955,958,959,961,962,965,966,970],{},"The second leak is the only one that actually lives in the reactivity system, and it's narrow. A watcher created ",[768,956,957],{},"asynchronously"," (after an ",[14,960,291],{},", inside a ",[14,963,964],{},"setTimeout",", in a promise callback) is never bound to the component, so it never auto-stops. The ",[27,967,969],{"href":29,"rel":968},[31],"same guide"," 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.\"",[10,972,973,974,976],{},"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 ",[14,975,20],{}," returns) and clear them on unmount. The broken shape first:",[362,978,979],{},[365,980,982],{"className":367,"code":981,"language":369,"meta":370,"style":370},"onMounted(() => {\n  setTimeout(() => {\n    watchEffect(() => {\n      document.title = `Unread: ${unread.value}`\n    })\n  }, 1000)\n})\n",[14,983,984,994,1005,1016,1038,1043,1054],{"__ignoreMap":370},[374,985,986,988,990,992],{"class":376,"line":377},[374,987,320],{"class":380},[374,989,384],{"class":383},[374,991,388],{"class":387},[374,993,391],{"class":383},[374,995,996,999,1001,1003],{"class":376,"line":394},[374,997,998],{"class":380},"  setTimeout",[374,1000,384],{"class":383},[374,1002,388],{"class":387},[374,1004,391],{"class":383},[374,1006,1007,1010,1012,1014],{"class":376,"line":420},[374,1008,1009],{"class":380},"    watchEffect",[374,1011,384],{"class":383},[374,1013,388],{"class":387},[374,1015,391],{"class":383},[374,1017,1018,1021,1024,1027,1030,1032,1035],{"class":376,"line":426},[374,1019,1020],{"class":383},"      document.title ",[374,1022,1023],{"class":387},"=",[374,1025,1026],{"class":405}," `Unread: ${",[374,1028,1029],{"class":383},"unread",[374,1031,594],{"class":405},[374,1033,1034],{"class":383},"value",[374,1036,1037],{"class":405},"}`\n",[374,1039,1040],{"class":376,"line":433},[374,1041,1042],{"class":383},"    })\n",[374,1044,1045,1048,1051],{"class":376,"line":444},[374,1046,1047],{"class":383},"  }, ",[374,1049,1050],{"class":609},"1000",[374,1052,1053],{"class":383},")\n",[374,1055,1056],{"class":376,"line":451},[374,1057,423],{"class":383},[475,1059,1060],{},[365,1061,1063],{"className":367,"code":1062,"language":369,"meta":370,"style":370},"let timeoutId\nlet stop\n\nonMounted(() => {\n  timeoutId = setTimeout(() => {\n    stop = watchEffect(() => {\n      document.title = `Unread: ${unread.value}`\n    })\n  }, 1000)\n})\n\nonUnmounted(() => {\n  clearTimeout(timeoutId)\n  stop?.()\n})\n",[14,1064,1065,1073,1080,1084,1094,1110,1126,1142,1146,1154,1158,1162,1173,1182,1191],{"__ignoreMap":370},[374,1066,1067,1070],{"class":376,"line":377},[374,1068,1069],{"class":387},"let",[374,1071,1072],{"class":383}," timeoutId\n",[374,1074,1075,1077],{"class":376,"line":394},[374,1076,1069],{"class":387},[374,1078,1079],{"class":383}," stop\n",[374,1081,1082],{"class":376,"line":420},[374,1083,430],{"emptyLinePlaceholder":429},[374,1085,1086,1088,1090,1092],{"class":376,"line":426},[374,1087,320],{"class":380},[374,1089,384],{"class":383},[374,1091,388],{"class":387},[374,1093,391],{"class":383},[374,1095,1096,1099,1101,1104,1106,1108],{"class":376,"line":433},[374,1097,1098],{"class":383},"  timeoutId ",[374,1100,1023],{"class":387},[374,1102,1103],{"class":380}," setTimeout",[374,1105,384],{"class":383},[374,1107,388],{"class":387},[374,1109,391],{"class":383},[374,1111,1112,1115,1117,1120,1122,1124],{"class":376,"line":444},[374,1113,1114],{"class":383},"    stop ",[374,1116,1023],{"class":387},[374,1118,1119],{"class":380}," watchEffect",[374,1121,384],{"class":383},[374,1123,388],{"class":387},[374,1125,391],{"class":383},[374,1127,1128,1130,1132,1134,1136,1138,1140],{"class":376,"line":451},[374,1129,1020],{"class":383},[374,1131,1023],{"class":387},[374,1133,1026],{"class":405},[374,1135,1029],{"class":383},[374,1137,594],{"class":405},[374,1139,1034],{"class":383},[374,1141,1037],{"class":405},[374,1143,1144],{"class":376,"line":471},[374,1145,1042],{"class":383},[374,1147,1148,1150,1152],{"class":376,"line":543},[374,1149,1047],{"class":383},[374,1151,1050],{"class":609},[374,1153,1053],{"class":383},[374,1155,1156],{"class":376,"line":554},[374,1157,423],{"class":383},[374,1159,1160],{"class":376,"line":567},[374,1161,430],{"emptyLinePlaceholder":429},[374,1163,1165,1167,1169,1171],{"class":376,"line":1164},12,[374,1166,167],{"class":380},[374,1168,384],{"class":383},[374,1170,388],{"class":387},[374,1172,391],{"class":383},[374,1174,1176,1179],{"class":376,"line":1175},13,[374,1177,1178],{"class":380},"  clearTimeout",[374,1180,1181],{"class":383},"(timeoutId)\n",[374,1183,1185,1188],{"class":376,"line":1184},14,[374,1186,1187],{"class":380},"  stop",[374,1189,1190],{"class":383},"?.()\n",[374,1192,1194],{"class":376,"line":1193},15,[374,1195,423],{"class":383},[10,1197,1198],{},"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.",[74,1200,1202],{"id":1201},"handles-and-references-that-outlive-the-component","Handles and references that outlive the component",[10,1204,1205],{},"Two leak sources left, and they're the ones Chrome's heap snapshot tends to surface as \"detached\" nodes.",[10,1207,1208],{},"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.",[362,1210,1211],{},[365,1212,1214],{"className":367,"code":1213,"language":369,"meta":370,"style":370},"let chart\n\nonMounted(() => {\n  chart = new Chart(canvas.value, config)\n})\n",[14,1215,1216,1223,1227,1237,1252],{"__ignoreMap":370},[374,1217,1218,1220],{"class":376,"line":377},[374,1219,1069],{"class":387},[374,1221,1222],{"class":383}," chart\n",[374,1224,1225],{"class":376,"line":394},[374,1226,430],{"emptyLinePlaceholder":429},[374,1228,1229,1231,1233,1235],{"class":376,"line":420},[374,1230,320],{"class":380},[374,1232,384],{"class":383},[374,1234,388],{"class":387},[374,1236,391],{"class":383},[374,1238,1239,1242,1244,1246,1249],{"class":376,"line":426},[374,1240,1241],{"class":383},"  chart ",[374,1243,1023],{"class":387},[374,1245,616],{"class":387},[374,1247,1248],{"class":380}," Chart",[374,1250,1251],{"class":383},"(canvas.value, config)\n",[374,1253,1254],{"class":376,"line":433},[374,1255,423],{"class":383},[475,1257,1258],{},[365,1259,1261],{"className":367,"code":1260,"language":369,"meta":370,"style":370},"let chart\n\nonMounted(() => {\n  chart = new Chart(canvas.value, config)\n})\n\nonUnmounted(() => {\n  chart.destroy()\n})\n",[14,1262,1263,1269,1273,1283,1295,1299,1303,1313,1323],{"__ignoreMap":370},[374,1264,1265,1267],{"class":376,"line":377},[374,1266,1069],{"class":387},[374,1268,1222],{"class":383},[374,1270,1271],{"class":376,"line":394},[374,1272,430],{"emptyLinePlaceholder":429},[374,1274,1275,1277,1279,1281],{"class":376,"line":420},[374,1276,320],{"class":380},[374,1278,384],{"class":383},[374,1280,388],{"class":387},[374,1282,391],{"class":383},[374,1284,1285,1287,1289,1291,1293],{"class":376,"line":426},[374,1286,1241],{"class":383},[374,1288,1023],{"class":387},[374,1290,616],{"class":387},[374,1292,1248],{"class":380},[374,1294,1251],{"class":383},[374,1296,1297],{"class":376,"line":433},[374,1298,423],{"class":383},[374,1300,1301],{"class":376,"line":444},[374,1302,430],{"emptyLinePlaceholder":429},[374,1304,1305,1307,1309,1311],{"class":376,"line":451},[374,1306,167],{"class":380},[374,1308,384],{"class":383},[374,1310,388],{"class":387},[374,1312,391],{"class":383},[374,1314,1315,1318,1321],{"class":376,"line":471},[374,1316,1317],{"class":383},"  chart.",[374,1319,1320],{"class":380},"destroy",[374,1322,722],{"class":383},[374,1324,1325],{"class":376,"line":543},[374,1326,423],{"class":383},[10,1328,1329,1330,1332,1333,1336],{},"The subtler source is a reference you park somewhere long-lived. Module scope is the classic trap: a ",[14,1331,606],{}," 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 ",[14,1334,1335],{},"off()",", or a store array that accumulates component data without bound. Module scope in its plainest form is a cache nobody ever prunes:",[362,1338,1339],{},[365,1340,1342],{"className":367,"code":1341,"language":369,"meta":370,"style":370},"\u002F\u002F module scope — and nothing ever removes an entry\nconst history = new Map()\n\nexport function useHistory(key, entry) {\n  history.set(key, entry)\n}\n",[14,1343,1344,1349,1365,1369,1393,1404],{"__ignoreMap":370},[374,1345,1346],{"class":376,"line":377},[374,1347,1348],{"class":447},"\u002F\u002F module scope — and nothing ever removes an entry\n",[374,1350,1351,1353,1356,1358,1360,1363],{"class":376,"line":394},[374,1352,606],{"class":387},[374,1354,1355],{"class":609}," history",[374,1357,613],{"class":387},[374,1359,616],{"class":387},[374,1361,1362],{"class":380}," Map",[374,1364,722],{"class":383},[374,1366,1367],{"class":376,"line":420},[374,1368,430],{"emptyLinePlaceholder":429},[374,1370,1371,1374,1377,1380,1382,1385,1387,1390],{"class":376,"line":426},[374,1372,1373],{"class":387},"export",[374,1375,1376],{"class":387}," function",[374,1378,1379],{"class":380}," useHistory",[374,1381,402],{"class":383},[374,1383,1384],{"class":806},"key",[374,1386,136],{"class":383},[374,1388,1389],{"class":806},"entry",[374,1391,1392],{"class":383},") {\n",[374,1394,1395,1398,1401],{"class":376,"line":433},[374,1396,1397],{"class":383},"  history.",[374,1399,1400],{"class":380},"set",[374,1402,1403],{"class":383},"(key, entry)\n",[374,1405,1406],{"class":376,"line":444},[374,1407,505],{"class":383},[475,1409,1410],{},[365,1411,1413],{"className":367,"code":1412,"language":369,"meta":370,"style":370},"import { onScopeDispose } from 'vue'\n\n\u002F\u002F same Map, same signature — the only change is registering the teardown\nconst history = new Map()\n\nexport function useHistory(key, entry) {\n  history.set(key, entry)\n  onScopeDispose(() => history.delete(key))\n}\n",[14,1414,1415,1426,1430,1435,1449,1453,1471,1479,1497],{"__ignoreMap":370},[374,1416,1417,1419,1422,1424],{"class":376,"line":377},[374,1418,857],{"class":387},[374,1420,1421],{"class":383}," { onScopeDispose } ",[374,1423,863],{"class":387},[374,1425,866],{"class":405},[374,1427,1428],{"class":376,"line":394},[374,1429,430],{"emptyLinePlaceholder":429},[374,1431,1432],{"class":376,"line":420},[374,1433,1434],{"class":447},"\u002F\u002F same Map, same signature — the only change is registering the teardown\n",[374,1436,1437,1439,1441,1443,1445,1447],{"class":376,"line":426},[374,1438,606],{"class":387},[374,1440,1355],{"class":609},[374,1442,613],{"class":387},[374,1444,616],{"class":387},[374,1446,1362],{"class":380},[374,1448,722],{"class":383},[374,1450,1451],{"class":376,"line":433},[374,1452,430],{"emptyLinePlaceholder":429},[374,1454,1455,1457,1459,1461,1463,1465,1467,1469],{"class":376,"line":444},[374,1456,1373],{"class":387},[374,1458,1376],{"class":387},[374,1460,1379],{"class":380},[374,1462,402],{"class":383},[374,1464,1384],{"class":806},[374,1466,136],{"class":383},[374,1468,1389],{"class":806},[374,1470,1392],{"class":383},[374,1472,1473,1475,1477],{"class":376,"line":451},[374,1474,1397],{"class":383},[374,1476,1400],{"class":380},[374,1478,1403],{"class":383},[374,1480,1481,1484,1486,1488,1491,1494],{"class":376,"line":471},[374,1482,1483],{"class":380},"  onScopeDispose",[374,1485,384],{"class":383},[374,1487,388],{"class":387},[374,1489,1490],{"class":383}," history.",[374,1492,1493],{"class":380},"delete",[374,1495,1496],{"class":383},"(key))\n",[374,1498,1499],{"class":376,"line":543},[374,1500,505],{"class":383},[10,1502,1503,1505,1506,1508,1509,1512,1513,1516,1517,1520,1521,1526,1527,1529,1530,1533],{},[14,1504,170],{}," rather than ",[14,1507,167],{}," here, because ",[14,1510,1511],{},"useHistory"," is a composable and a composable can run in a scope that is not a component's. Swapping the ",[14,1514,1515],{},"Map"," for a ",[14,1518,1519],{},"WeakMap"," is no substitute for that teardown: WeakMap keys must be ",[27,1522,1525],{"href":1523,"rel":1524},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FJavaScript\u002FReference\u002FGlobal_Objects\u002FWeakMap\u002Fset",[31],"objects or non-registered symbols",", so a string or numeric ",[14,1528,1384],{}," throws a ",[14,1531,1532],{},"TypeError",", 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.",[10,1535,1536,1537,1540,1541,1544,1545,1547,1548,1540,1553,1558,1559,1562,1563,136,1565,1568,1569,1571],{},"The same long-lived-reference trap shows up in Pinia's own ",[14,1538,1539],{},"$subscribe"," and ",[14,1542,1543],{},"$onAction",". The safe case is the default: registered inside an active effect scope — a component's ",[14,1546,52],{}," included — both bind to that scope and are removed when it disposes (per the Pinia docs on ",[27,1549,1552],{"href":1550,"rel":1551},"https:\u002F\u002Fpinia.vuejs.org\u002Fcore-concepts\u002Fstate.html",[31],"state",[27,1554,1557],{"href":1555,"rel":1556},"https:\u002F\u002Fpinia.vuejs.org\u002Fcore-concepts\u002Factions.html",[31],"actions","). Detach them (",[14,1560,1561],{},"{ detached: true }"," on ",[14,1564,1539],{},[14,1566,1567],{},"true"," as the second argument to ",[14,1570,1543],{},"), or register them where no scope is active, and the returned unsubscribe function becomes yours to call. The flag is the whole difference:",[362,1573,1574],{},[365,1575,1577],{"className":367,"code":1576,"language":369,"meta":370,"style":370},"const cart = useCartStore()\n\n\u002F\u002F detached opts out of the scope cleanup — and nothing replaces it\ncart.$subscribe(saveCart, { detached: true })\n",[14,1578,1579,1593,1597,1602],{"__ignoreMap":370},[374,1580,1581,1583,1586,1588,1591],{"class":376,"line":377},[374,1582,606],{"class":387},[374,1584,1585],{"class":609}," cart",[374,1587,613],{"class":387},[374,1589,1590],{"class":380}," useCartStore",[374,1592,722],{"class":383},[374,1594,1595],{"class":376,"line":394},[374,1596,430],{"emptyLinePlaceholder":429},[374,1598,1599],{"class":376,"line":420},[374,1600,1601],{"class":447},"\u002F\u002F detached opts out of the scope cleanup — and nothing replaces it\n",[374,1603,1604,1607,1609,1612,1614],{"class":376,"line":426},[374,1605,1606],{"class":383},"cart.",[374,1608,1539],{"class":380},[374,1610,1611],{"class":383},"(saveCart, { detached: ",[374,1613,1567],{"class":609},[374,1615,1616],{"class":383}," })\n",[475,1618,1619],{},[365,1620,1622],{"className":367,"code":1621,"language":369,"meta":370,"style":370},"const cart = useCartStore()\n\n\u002F\u002F no detach: the subscription dies with the scope that registered it\ncart.$subscribe(saveCart)\n",[14,1623,1624,1636,1640,1645],{"__ignoreMap":370},[374,1625,1626,1628,1630,1632,1634],{"class":376,"line":377},[374,1627,606],{"class":387},[374,1629,1585],{"class":609},[374,1631,613],{"class":387},[374,1633,1590],{"class":380},[374,1635,722],{"class":383},[374,1637,1638],{"class":376,"line":394},[374,1639,430],{"emptyLinePlaceholder":429},[374,1641,1642],{"class":376,"line":420},[374,1643,1644],{"class":447},"\u002F\u002F no detach: the subscription dies with the scope that registered it\n",[374,1646,1647,1649,1651],{"class":376,"line":426},[374,1648,1606],{"class":383},[374,1650,1539],{"class":380},[374,1652,1653],{"class":383},"(saveCart)\n",[10,1655,1656],{},"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.",[74,1658,1660],{"id":1659},"composables-give-the-effects-a-scope-to-die-with","Composables: give the effects a scope to die with",[10,1662,1663,1664,1666,1667,1669,1670,1673,1674,1676,1677,1679],{},"Composables complicate the mental model in one specific way. ",[14,1665,167],{}," needs a component instance; ",[14,1668,170],{}," 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 ",[14,1671,1672],{},"effectScope()",", where there is no instance of its own for ",[14,1675,167],{}," to bind to. A composable that relies on ",[14,1678,167],{}," for teardown is betting on a component being there to hold it.",[10,1681,1682,1684,1685,1688,1689,1691,1692,1697,1698,1701,1702,1705,1706,1708],{},[14,1683,170],{}," is the fix: it registers teardown on the ",[768,1686,1687],{},"current effect scope"," 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 ",[14,1690,167],{}," in reusable composition functions\" (",[27,1693,1696],{"href":1694,"rel":1695},"https:\u002F\u002Fvuejs.org\u002Fapi\u002Freactivity-advanced.html#onscopedispose",[31],"Reactivity API: Advanced","). One limit rides along: the scope has to be ",[768,1699,1700],{},"active",". Outside any scope at all — module top level, a router guard, a Vue plugin's ",[14,1703,1704],{},"install()"," — it registers nothing and warns in dev only, silently in production, exactly as ",[14,1707,167],{}," does. Neither hook rescues that case; code that runs there owns its teardown outright.",[362,1710,1711],{},[365,1712,1714],{"className":367,"code":1713,"language":369,"meta":370,"style":370},"export function useSocket(url) {\n  const socket = new WebSocket(url)\n  onUnmounted(() => socket.close())\n  return socket\n}\n",[14,1715,1716,1732,1748,1763,1771],{"__ignoreMap":370},[374,1717,1718,1720,1722,1725,1727,1730],{"class":376,"line":377},[374,1719,1373],{"class":387},[374,1721,1376],{"class":387},[374,1723,1724],{"class":380}," useSocket",[374,1726,402],{"class":383},[374,1728,1729],{"class":806},"url",[374,1731,1392],{"class":383},[374,1733,1734,1736,1738,1740,1742,1745],{"class":376,"line":394},[374,1735,819],{"class":387},[374,1737,822],{"class":609},[374,1739,613],{"class":387},[374,1741,616],{"class":387},[374,1743,1744],{"class":380}," WebSocket",[374,1746,1747],{"class":383},"(url)\n",[374,1749,1750,1753,1755,1757,1759,1761],{"class":376,"line":420},[374,1751,1752],{"class":380},"  onUnmounted",[374,1754,384],{"class":383},[374,1756,388],{"class":387},[374,1758,916],{"class":383},[374,1760,919],{"class":380},[374,1762,922],{"class":383},[374,1764,1765,1768],{"class":376,"line":426},[374,1766,1767],{"class":387},"  return",[374,1769,1770],{"class":383}," socket\n",[374,1772,1773],{"class":376,"line":433},[374,1774,505],{"class":383},[475,1776,1777],{},[365,1778,1780],{"className":367,"code":1779,"language":369,"meta":370,"style":370},"import { onScopeDispose } from 'vue'\n\nexport function useSocket(url) {\n  const socket = new WebSocket(url)\n  onScopeDispose(() => socket.close())\n  return socket\n}\n",[14,1781,1782,1792,1796,1810,1824,1838,1844],{"__ignoreMap":370},[374,1783,1784,1786,1788,1790],{"class":376,"line":377},[374,1785,857],{"class":387},[374,1787,1421],{"class":383},[374,1789,863],{"class":387},[374,1791,866],{"class":405},[374,1793,1794],{"class":376,"line":394},[374,1795,430],{"emptyLinePlaceholder":429},[374,1797,1798,1800,1802,1804,1806,1808],{"class":376,"line":420},[374,1799,1373],{"class":387},[374,1801,1376],{"class":387},[374,1803,1724],{"class":380},[374,1805,402],{"class":383},[374,1807,1729],{"class":806},[374,1809,1392],{"class":383},[374,1811,1812,1814,1816,1818,1820,1822],{"class":376,"line":426},[374,1813,819],{"class":387},[374,1815,822],{"class":609},[374,1817,613],{"class":387},[374,1819,616],{"class":387},[374,1821,1744],{"class":380},[374,1823,1747],{"class":383},[374,1825,1826,1828,1830,1832,1834,1836],{"class":376,"line":433},[374,1827,1483],{"class":380},[374,1829,384],{"class":383},[374,1831,388],{"class":387},[374,1833,916],{"class":383},[374,1835,919],{"class":380},[374,1837,922],{"class":383},[374,1839,1840,1842],{"class":376,"line":444},[374,1841,1767],{"class":387},[374,1843,1770],{"class":383},[374,1845,1846],{"class":376,"line":451},[374,1847,505],{"class":383},[10,1849,1850,1851,1853,1854,1859],{},"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 ",[14,1852,173],{}," 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 ",[27,1855,1858],{"href":1856,"rel":1857},"https:\u002F\u002Fgithub.com\u002Fvuejs\u002Frfcs\u002Fblob\u002Fmaster\u002Factive-rfcs\u002F0041-reactivity-effect-scope.md",[31],"RFC 0041","). Watch how many handles the caller has to remember in the first version.",[362,1861,1862],{},[365,1863,1865],{"className":367,"code":1864,"language":369,"meta":370,"style":370},"\u002F\u002F every effect returns its own stop handle, tracked by hand\nconst stopSync = watch(source, sync)\nconst stopReport = watchEffect(report)\n\nfunction teardown() {\n  stopSync()\n  stopReport()\n}\n",[14,1866,1867,1872,1887,1901,1905,1914,1921,1928],{"__ignoreMap":370},[374,1868,1869],{"class":376,"line":377},[374,1870,1871],{"class":447},"\u002F\u002F every effect returns its own stop handle, tracked by hand\n",[374,1873,1874,1876,1879,1881,1884],{"class":376,"line":394},[374,1875,606],{"class":387},[374,1877,1878],{"class":609}," stopSync",[374,1880,613],{"class":387},[374,1882,1883],{"class":380}," watch",[374,1885,1886],{"class":383},"(source, sync)\n",[374,1888,1889,1891,1894,1896,1898],{"class":376,"line":420},[374,1890,606],{"class":387},[374,1892,1893],{"class":609}," stopReport",[374,1895,613],{"class":387},[374,1897,1119],{"class":380},[374,1899,1900],{"class":383},"(report)\n",[374,1902,1903],{"class":376,"line":426},[374,1904,430],{"emptyLinePlaceholder":429},[374,1906,1907,1909,1912],{"class":376,"line":433},[374,1908,486],{"class":387},[374,1910,1911],{"class":380}," teardown",[374,1913,492],{"class":383},[374,1915,1916,1919],{"class":376,"line":444},[374,1917,1918],{"class":380},"  stopSync",[374,1920,722],{"class":383},[374,1922,1923,1926],{"class":376,"line":451},[374,1924,1925],{"class":380},"  stopReport",[374,1927,722],{"class":383},[374,1929,1930],{"class":376,"line":471},[374,1931,505],{"class":383},[475,1933,1934],{},[365,1935,1937],{"className":367,"code":1936,"language":369,"meta":370,"style":370},"import { effectScope } from 'vue'\n\nconst scope = effectScope()\n\nscope.run(() => {\n  watch(source, sync)\n  watchEffect(report)\n})\n\nfunction teardown() {\n  \u002F\u002F one call disposes every effect in the scope\n  scope.stop()\n}\n",[14,1938,1939,1950,1954,1968,1972,1986,1993,2000,2004,2008,2016,2021,2031],{"__ignoreMap":370},[374,1940,1941,1943,1946,1948],{"class":376,"line":377},[374,1942,857],{"class":387},[374,1944,1945],{"class":383}," { effectScope } ",[374,1947,863],{"class":387},[374,1949,866],{"class":405},[374,1951,1952],{"class":376,"line":394},[374,1953,430],{"emptyLinePlaceholder":429},[374,1955,1956,1958,1961,1963,1966],{"class":376,"line":420},[374,1957,606],{"class":387},[374,1959,1960],{"class":609}," scope",[374,1962,613],{"class":387},[374,1964,1965],{"class":380}," effectScope",[374,1967,722],{"class":383},[374,1969,1970],{"class":376,"line":426},[374,1971,430],{"emptyLinePlaceholder":429},[374,1973,1974,1977,1980,1982,1984],{"class":376,"line":433},[374,1975,1976],{"class":383},"scope.",[374,1978,1979],{"class":380},"run",[374,1981,384],{"class":383},[374,1983,388],{"class":387},[374,1985,391],{"class":383},[374,1987,1988,1991],{"class":376,"line":444},[374,1989,1990],{"class":380},"  watch",[374,1992,1886],{"class":383},[374,1994,1995,1998],{"class":376,"line":451},[374,1996,1997],{"class":380},"  watchEffect",[374,1999,1900],{"class":383},[374,2001,2002],{"class":376,"line":471},[374,2003,423],{"class":383},[374,2005,2006],{"class":376,"line":543},[374,2007,430],{"emptyLinePlaceholder":429},[374,2009,2010,2012,2014],{"class":376,"line":554},[374,2011,486],{"class":387},[374,2013,1911],{"class":380},[374,2015,492],{"class":383},[374,2017,2018],{"class":376,"line":567},[374,2019,2020],{"class":447},"  \u002F\u002F one call disposes every effect in the scope\n",[374,2022,2023,2026,2029],{"class":376,"line":1164},[374,2024,2025],{"class":383},"  scope.",[374,2027,2028],{"class":380},"stop",[374,2030,722],{"class":383},[374,2032,2033],{"class":376,"line":1175},[374,2034,505],{"class":383},[74,2036,2038],{"id":2037},"the-counter-vue-cleans-up-and-vueuse-handles-the-rest","The counter: \"Vue cleans up, and VueUse handles the rest\"",[10,2040,2041],{},"Skeptics deride all of this as solved-problem fear-mongering, and the objection comes in two parts worth taking seriously.",[10,2043,2044,2045,2048],{},"The first part is correct on the facts: Vue 3 ",[768,2046,2047],{},"does"," 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.",[10,2050,2051,2052,2057,2058,2061,2062,2064,2065,2072,2073,2078,2079,2082,2083,594],{},"The second part is the better argument: don't hand-roll any of this; reach for ",[27,2053,2056],{"href":2054,"rel":2055},"https:\u002F\u002Fvueuse.org\u002Fcore\u002FuseEventListener\u002F",[31],"VueUse",", whose composables wire teardown in for you. It's the right default, not a crutch. ",[14,2059,2060],{},"useEventListener"," registers on mount, and its docs vouch for the behavior plainly: it runs \"",[14,2063,456],{}," automatically on unmounted\". ",[27,2066,2069],{"href":2067,"rel":2068},"https:\u002F\u002Fvueuse.org\u002Fshared\u002FuseIntervalFn\u002F",[31],[14,2070,2071],{},"useIntervalFn"," does the same for timers, though you have to read its ",[27,2074,2077],{"href":2075,"rel":2076},"https:\u002F\u002Fgithub.com\u002Fvueuse\u002Fvueuse\u002Fblob\u002Fmain\u002Fpackages\u002Fshared\u002FuseIntervalFn\u002Findex.ts",[31],"source"," to see why: it hands ",[14,2080,2081],{},"pause"," straight to ",[14,2084,2085],{},"tryOnScopeDispose",[365,2087,2089],{"className":367,"code":2088,"language":369,"meta":370,"style":370},"import { useEventListener, useIntervalFn } from '@vueuse\u002Fcore'\n\nuseEventListener('resize', onResize)\nuseIntervalFn(refresh, 5000)\n",[14,2090,2091,2103,2107,2117],{"__ignoreMap":370},[374,2092,2093,2095,2098,2100],{"class":376,"line":377},[374,2094,857],{"class":387},[374,2096,2097],{"class":383}," { useEventListener, useIntervalFn } ",[374,2099,863],{"class":387},[374,2101,2102],{"class":405}," '@vueuse\u002Fcore'\n",[374,2104,2105],{"class":376,"line":394},[374,2106,430],{"emptyLinePlaceholder":429},[374,2108,2109,2111,2113,2115],{"class":376,"line":420},[374,2110,2060],{"class":380},[374,2112,402],{"class":383},[374,2114,406],{"class":405},[374,2116,532],{"class":383},[374,2118,2119,2121,2124,2127],{"class":376,"line":426},[374,2120,2071],{"class":380},[374,2122,2123],{"class":383},"(refresh, ",[374,2125,2126],{"class":609},"5000",[374,2128,1053],{"class":383},[10,2130,2131,2132,2134,2135,2137,2138,2141],{},"That's the listener section and the polling interval, both deleted. No ",[14,2133,320],{},", no ",[14,2136,167],{},", no reference-matching trap, no timer id to keep. The rule still matters, because the library is the rule ",[768,2139,2140],{},"applied",", 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 WebSocket subprotocol, 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.",[74,2143,2145],{"id":2144},"detection-confirm-the-leak-before-you-chase-it","Detection: confirm the leak before you chase it",[10,2147,2148,2149,2154,2155,2160,2161,2166],{},"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 ",[27,2150,2153],{"href":2151,"rel":2152},"https:\u002F\u002Fdevtools.vuejs.org\u002Fgetting-started\u002Ffeatures",[31],"Vue DevTools"," 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 ",[27,2156,2159],{"href":2157,"rel":2158},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fdevtools\u002Fmemory-problems\u002Fheap-snapshots\u002F",[31],"the Chrome DevTools heap-snapshot guide","): 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. ",[27,2162,2165],{"href":2163,"rel":2164},"https:\u002F\u002Fdeveloper.chrome.com\u002Fdocs\u002Fdevtools\u002Fmemory-problems\u002F",[31],"Filter the class list by \"Detached\""," to find DOM kept alive by stale references, then read the Retainers pane to see exactly what's holding on.",[10,2168,2169,2170,2178],{},"For teams that want deterministic checks in CI, ",[27,2171,2174,2175],{"href":2172,"rel":2173},"https:\u002F\u002Ffacebook.github.io\u002Fmemlab\u002F",[31],"Meta's ",[14,2176,2177],{},"memlab"," automates the snapshot-diff loop; for ad-hoc debugging, the manual panel is the first thing to reach for.",[10,2180,2181,2182,2184,2185,2187],{},"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 ",[14,2183,272],{}," fired with the id ",[14,2186,39],{}," handed back, which makes it a regression guard on a leak already found rather than a way to find one.",[365,2189,2191],{"className":367,"code":2190,"language":369,"meta":370,"style":370},"\u002F\u002F @vitest-environment happy-dom\nimport { mount } from '@vue\u002Ftest-utils'\nimport { expect, it, vi } from 'vitest'\nimport Poller from '.\u002FPoller.vue'\n\nit('clears its interval on unmount', () => {\n  const setSpy = vi.spyOn(globalThis, 'setInterval')\n  const clearSpy = vi.spyOn(globalThis, 'clearInterval')\n\n  const wrapper = mount(Poller)\n  expect(setSpy).toHaveBeenCalledOnce()\n\n  wrapper.unmount()\n  expect(clearSpy).toHaveBeenCalledWith(setSpy.mock.results[0].value)\n})\n",[14,2192,2193,2198,2210,2222,2234,2238,2254,2277,2297,2301,2316,2329,2333,2343,2362],{"__ignoreMap":370},[374,2194,2195],{"class":376,"line":377},[374,2196,2197],{"class":447},"\u002F\u002F @vitest-environment happy-dom\n",[374,2199,2200,2202,2205,2207],{"class":376,"line":394},[374,2201,857],{"class":387},[374,2203,2204],{"class":383}," { mount } ",[374,2206,863],{"class":387},[374,2208,2209],{"class":405}," '@vue\u002Ftest-utils'\n",[374,2211,2212,2214,2217,2219],{"class":376,"line":420},[374,2213,857],{"class":387},[374,2215,2216],{"class":383}," { expect, it, vi } ",[374,2218,863],{"class":387},[374,2220,2221],{"class":405}," 'vitest'\n",[374,2223,2224,2226,2229,2231],{"class":376,"line":426},[374,2225,857],{"class":387},[374,2227,2228],{"class":383}," Poller ",[374,2230,863],{"class":387},[374,2232,2233],{"class":405}," '.\u002FPoller.vue'\n",[374,2235,2236],{"class":376,"line":433},[374,2237,430],{"emptyLinePlaceholder":429},[374,2239,2240,2243,2245,2248,2250,2252],{"class":376,"line":444},[374,2241,2242],{"class":380},"it",[374,2244,402],{"class":383},[374,2246,2247],{"class":405},"'clears its interval on unmount'",[374,2249,409],{"class":383},[374,2251,388],{"class":387},[374,2253,391],{"class":383},[374,2255,2256,2258,2261,2263,2266,2269,2272,2275],{"class":376,"line":451},[374,2257,819],{"class":387},[374,2259,2260],{"class":609}," setSpy",[374,2262,613],{"class":387},[374,2264,2265],{"class":383}," vi.",[374,2267,2268],{"class":380},"spyOn",[374,2270,2271],{"class":383},"(globalThis, ",[374,2273,2274],{"class":405},"'setInterval'",[374,2276,1053],{"class":383},[374,2278,2279,2281,2284,2286,2288,2290,2292,2295],{"class":376,"line":471},[374,2280,819],{"class":387},[374,2282,2283],{"class":609}," clearSpy",[374,2285,613],{"class":387},[374,2287,2265],{"class":383},[374,2289,2268],{"class":380},[374,2291,2271],{"class":383},[374,2293,2294],{"class":405},"'clearInterval'",[374,2296,1053],{"class":383},[374,2298,2299],{"class":376,"line":543},[374,2300,430],{"emptyLinePlaceholder":429},[374,2302,2303,2305,2308,2310,2313],{"class":376,"line":554},[374,2304,819],{"class":387},[374,2306,2307],{"class":609}," wrapper",[374,2309,613],{"class":387},[374,2311,2312],{"class":380}," mount",[374,2314,2315],{"class":383},"(Poller)\n",[374,2317,2318,2321,2324,2327],{"class":376,"line":567},[374,2319,2320],{"class":380},"  expect",[374,2322,2323],{"class":383},"(setSpy).",[374,2325,2326],{"class":380},"toHaveBeenCalledOnce",[374,2328,722],{"class":383},[374,2330,2331],{"class":376,"line":1164},[374,2332,430],{"emptyLinePlaceholder":429},[374,2334,2335,2338,2341],{"class":376,"line":1175},[374,2336,2337],{"class":383},"  wrapper.",[374,2339,2340],{"class":380},"unmount",[374,2342,722],{"class":383},[374,2344,2345,2347,2350,2353,2356,2359],{"class":376,"line":1184},[374,2346,2320],{"class":380},[374,2348,2349],{"class":383},"(clearSpy).",[374,2351,2352],{"class":380},"toHaveBeenCalledWith",[374,2354,2355],{"class":383},"(setSpy.mock.results[",[374,2357,2358],{"class":609},"0",[374,2360,2361],{"class":383},"].value)\n",[374,2363,2364],{"class":376,"line":1193},[374,2365,423],{"class":383},[10,2367,2368,2371,2372,2374,2375,2378,2379,2382,2383,93,2386,2389,2390,2393,2394,2396,2397,2400,2401,2404,2405,2408,2409,2412],{},[14,2369,2370],{},"Poller"," is any component that starts an interval in ",[14,2373,320],{},". Vitest defaults to a ",[14,2376,2377],{},"node"," environment, so ",[14,2380,2381],{},"mount"," needs ",[14,2384,2385],{},"happy-dom",[14,2387,2388],{},"jsdom"," switched on. Skip ",[14,2391,2392],{},"vi.useFakeTimers()",": it replaces the timer globals, and installing it after the spies leaves them recording nothing. The ",[14,2395,39],{}," assertion is what keeps the test honest, since without it a component that starts no timer at all passes. Restore the spies as well — ",[14,2398,2399],{},"vi.restoreAllMocks()"," in an ",[14,2402,2403],{},"afterEach",", or ",[14,2406,2407],{},"restoreMocks: true"," — because ",[14,2410,2411],{},"vi.spyOn"," 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.",[10,2414,2415,2416,2423,2424,2429,2430,2433,2434,2437,2438,2443,2444,93,2447,2450,2451,2454,2455,2463,2464,2467,2468,2473],{},"None of that watches production. The nearest thing to a field reading is ",[27,2417,2420],{"href":2418,"rel":2419},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FPerformance\u002FmeasureUserAgentSpecificMemory",[31],[14,2421,2422],{},"performance.measureUserAgentSpecificMemory()",": Chromium-only, still a ",[27,2425,2428],{"href":2426,"rel":2427},"https:\u002F\u002Fwicg.github.io\u002Fperformance-measure-memory\u002F",[31],"WICG draft"," rather than a W3C standard, and rejected with a ",[14,2431,2432],{},"SecurityError"," unless the document is cross-origin isolated under COOP and COEP. Its predecessor ",[14,2435,2436],{},"performance.memory"," is ",[27,2439,2442],{"href":2440,"rel":2441},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FPerformance\u002Fmemory",[31],"deprecated and non-standard",", not a fallback. The out-of-memory kill is observable only after the fact and only out-of-band: declare a ",[14,2445,2446],{},"crash-reporting",[14,2448,2449],{},"default"," endpoint in a ",[14,2452,2453],{},"Reporting-Endpoints"," header and the browser posts a ",[27,2456,2459,2462],{"href":2457,"rel":2458},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FCrashReport",[31],[14,2460,2461],{},"crash"," report"," once the renderer is gone, tagged ",[14,2465,2466],{},"reason: \"oom\""," 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 ",[27,2469,2472],{"href":2470,"rel":2471},"https:\u002F\u002Fgithub.com\u002Fgetsentry\u002Fsentry-javascript\u002Fissues\u002F5280",[31],"deny it is detectable from inside the SDK",". That endpoint is the only signal that survives the kill.",[74,2475,2477],{"id":2476},"what-looks-like-a-leak-but-isnt","What looks like a leak but isn't",[10,2479,2480,2481,2484,2485,2492],{},"Three patterns set off false alarms worth pre-empting. ",[14,2482,2483],{},"\u003CKeepAlive>"," 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 ",[27,2486,2489],{"href":2487,"rel":2488},"https:\u002F\u002Fvuejs.org\u002Fguide\u002Fbuilt-ins\u002Fkeep-alive.html#lifecycle-of-cached-instance",[31],[14,2490,2491],{},"onDeactivated",". 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.",[74,2494,2496],{"id":2495},"what-to-actually-do","What to actually do",[10,2498,2499],{},"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.",[2501,2502,2503,2529],"best-practice",{},[10,2504,2505,2508,2509,2511,2512,2514,2515,2517,2518,2520,2521,2523,2524,133,2526,2528],{},[82,2506,2507],{},"In general:"," 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 — ",[14,2510,167],{}," inside a component, ",[14,2513,170],{}," in any other effect scope a composable might run in. The exceptions are the two rows with a seam of their own: ",[14,2516,176],{}," for a side effect started inside a watcher, ",[14,2519,173],{}," for several effects that must die as a unit. If ",[14,2522,2483],{}," caches the component, anything that should stop while it's hidden goes in ",[14,2525,2491],{},[14,2527,167],{}," won't run until the cache drops it. Confirm the leak with a two-snapshot heap comparison before you chase it.",[191,2530,2531,2541],{},[194,2532,2533],{},[197,2534,2535,2538],{},[200,2536,2537],{},"Case",[200,2539,2540],{},"Reach for",[207,2542,2543,2570,2595,2613,2626,2640,2653,2663,2677,2690],{},[197,2544,2545,2554],{},[212,2546,2547,2549,2550,2549,2552],{},[14,2548,39],{}," \u002F ",[14,2551,964],{},[14,2553,142],{},[212,2555,2556,2549,2558,2549,2561,2563,2564,2566,2567,2569],{},[14,2557,272],{},[14,2559,2560],{},"clearTimeout",[14,2562,752],{}," in ",[14,2565,167],{}," — or VueUse's ",[14,2568,2071],{}," for the timers",[197,2571,2572,2581],{},[212,2573,2574,2549,2577,2580],{},[14,2575,2576],{},"window",[14,2578,2579],{},"document"," \u002F emitter listener",[212,2582,2583,2585,2586,2589,2590,2592,2593],{},[14,2584,456],{}," (or the emitter's ",[14,2587,2588],{},".off()",") with the same handler reference in ",[14,2591,167],{}," — or ",[14,2594,2060],{},[197,2596,2597,2605],{},[212,2598,2599,136,2601,136,2603],{},[14,2600,145],{},[14,2602,148],{},[14,2604,738],{},[212,2606,2607,2549,2609,2563,2611],{},[14,2608,593],{},[14,2610,734],{},[14,2612,167],{},[197,2614,2615,2618],{},[212,2616,2617],{},"A side effect started inside a watcher",[212,2619,2620,2622,2623,2625],{},[14,2621,176],{}," (3.5+), or the ",[14,2624,937],{}," third argument",[197,2627,2628,2634],{},[212,2629,2630,2631,2633],{},"A watcher or effect created after an ",[14,2632,291],{}," or in a callback",[212,2635,2636,2637,2639],{},"create it synchronously if you can — otherwise capture the returned stop handle and call it, plus ",[14,2638,2560],{}," on the pending timer if a timer is what queued it",[197,2641,2642,2645],{},[212,2643,2644],{},"A third-party widget instance",[212,2646,2647,2648,2563,2651],{},"store the handle, call its ",[14,2649,2650],{},".destroy()",[14,2652,167],{},[197,2654,2655,2658],{},[212,2656,2657],{},"A composable that may run outside a component",[212,2659,2660,2662],{},[14,2661,170],{}," — it fires for any effect scope, not just component setup",[197,2664,2665,2668],{},[212,2666,2667],{},"Several effects to dispose as a unit, or reactivity outside any component",[212,2669,2670,2672,2673,2676],{},[14,2671,173],{}," — one ",[14,2674,2675],{},".stop()"," disposes every effect inside",[197,2678,2679,2682],{},[212,2680,2681],{},"Component data pushed into module \u002F global \u002F store scope",[212,2683,2684,2685,2592,2687,2689],{},"clear the reference in ",[14,2686,167],{},[14,2688,170],{}," if a composable is what parked it",[197,2691,2692,2697],{},[212,2693,2694,2695],{},"A detached Pinia ",[14,2696,1539],{},[212,2698,2699],{},"don't detach unless something outside the component owns its end — and then that owner calls the returned unsubscribe",[10,2701,2702,2703,2705],{},"The one pattern to keep in muscle memory, because timers are the case people forget most — and because it is exactly what ",[14,2704,2071],{}," wraps when you reach for VueUse instead:",[362,2707,2708],{},[365,2709,2711],{"className":367,"code":2710,"language":369,"meta":370,"style":370},"onMounted(() => {\n  setInterval(refresh, 5000)\n})\n",[14,2712,2713,2723,2734],{"__ignoreMap":370},[374,2714,2715,2717,2719,2721],{"class":376,"line":377},[374,2716,320],{"class":380},[374,2718,384],{"class":383},[374,2720,388],{"class":387},[374,2722,391],{"class":383},[374,2724,2725,2728,2730,2732],{"class":376,"line":394},[374,2726,2727],{"class":380},"  setInterval",[374,2729,2123],{"class":383},[374,2731,2126],{"class":609},[374,2733,1053],{"class":383},[374,2735,2736],{"class":376,"line":420},[374,2737,423],{"class":383},[475,2739,2740],{},[365,2741,2743],{"className":367,"code":2742,"language":369,"meta":370,"style":370},"let id\n\nonMounted(() => {\n  id = setInterval(refresh, 5000)\n})\n\nonUnmounted(() => clearInterval(id))\n",[14,2744,2745,2752,2756,2766,2782,2786,2790],{"__ignoreMap":370},[374,2746,2747,2749],{"class":376,"line":377},[374,2748,1069],{"class":387},[374,2750,2751],{"class":383}," id\n",[374,2753,2754],{"class":376,"line":394},[374,2755,430],{"emptyLinePlaceholder":429},[374,2757,2758,2760,2762,2764],{"class":376,"line":420},[374,2759,320],{"class":380},[374,2761,384],{"class":383},[374,2763,388],{"class":387},[374,2765,391],{"class":383},[374,2767,2768,2771,2773,2776,2778,2780],{"class":376,"line":426},[374,2769,2770],{"class":383},"  id ",[374,2772,1023],{"class":387},[374,2774,2775],{"class":380}," setInterval",[374,2777,2123],{"class":383},[374,2779,2126],{"class":609},[374,2781,1053],{"class":383},[374,2783,2784],{"class":376,"line":433},[374,2785,423],{"class":383},[374,2787,2788],{"class":376,"line":444},[374,2789,430],{"emptyLinePlaceholder":429},[374,2791,2792,2794,2796,2798,2801],{"class":376,"line":451},[374,2793,167],{"class":380},[374,2795,384],{"class":383},[374,2797,388],{"class":387},[374,2799,2800],{"class":380}," clearInterval",[374,2802,2803],{"class":383},"(id))\n",[10,2805,2806],{},"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.",[74,2808,2810],{"id":2809},"sources","Sources",[125,2812,2813,2819,2830,2837,2844,2857,2864,2871,2889,2896,2903,2910,2920,2930,2937,2949,2958,2968,2982,3000,3010,3017,3035,3048,3061,3068,3077,3084,3095,3108,3117],{},[128,2814,2815,2818],{},[27,2816,2817],{"href":949},"Cancelling API Requests in Vue 3 — eveko"," — companion piece on aborting in-flight requests, the canonical watcher side effect.",[128,2820,2821,2825,2826,2829],{},[27,2822,2824],{"href":2163,"rel":2823},[31],"Chrome DevTools: Fix memory problems — Google"," — the ",[14,2827,2828],{},"Detached"," class filter for finding DOM kept alive by stale references.",[128,2831,2832,2836],{},[27,2833,2835],{"href":2157,"rel":2834},[31],"Chrome DevTools: Record heap snapshots — Google"," — the two-snapshot Comparison workflow and the Retainers pane.",[128,2838,2839,2843],{},[27,2840,2842],{"href":117,"rel":2841},[31],"computed is no longer controlled by effectScope — vuejs\u002Fcore issue #11886, 2024"," — maintainer confirmation that a post-3.5 computed self-disposes rather than being stopped by its scope.",[128,2845,2846,2850,2851,2853,2854,2856],{},[27,2847,2849],{"href":2457,"rel":2848},[31],"CrashReport — MDN"," — the out-of-band ",[14,2852,2461],{}," report, delivered to a ",[14,2855,2453],{}," target after the renderer is gone.",[128,2858,2859,2863],{},[27,2860,2862],{"href":324,"rel":2861},[31],"ESLint rule: no-restricted-syntax — ESLint docs"," — the selector-based fallback: it can ban a call, but matches one node and never a pair.",[128,2865,2866,2870],{},[27,2867,2869],{"href":280,"rel":2868},[31],"ESLint rule: vue\u002Fno-watch-after-await — eslint-plugin-vue"," — the essential-preset rule for asynchronously registered watchers.",[128,2872,2873,2877,2878,2880,2881,2883,2884,2886,2887,594],{},[27,2874,2876],{"href":298,"rel":2875},[31],"ESLint rule: web-api-no-leaked-interval — @eslint-react"," — verifies ",[14,2879,39],{},"\u002F",[14,2882,272],{}," pairing, but only inside ",[14,2885,316],{},", so it never fires on ",[14,2888,320],{},[128,2890,2891,2895],{},[27,2892,2894],{"href":46,"rel":2893},[31],"Frontend Memory Leaks: a 500-repository study — StackInsight, 2026"," — leak-category prevalence data across 500 public repos, with per-framework finding counts.",[128,2897,2898,2902],{},[27,2899,2901],{"href":2426,"rel":2900},[31],"Measure Memory API — WICG draft"," — a Draft Community Group Report, explicitly not a W3C Standard nor on the standards track.",[128,2904,2905,2909],{},[27,2906,2908],{"href":2172,"rel":2907},[31],"memlab: JavaScript memory leak detector — Meta"," — automates the snapshot-diff loop for deterministic CI checks.",[128,2911,2912,2916,2917,2919],{},[27,2913,2915],{"href":65,"rel":2914},[31],"Nuxt State Management: best practices — Nuxt docs"," — why a module-scope ",[14,2918,71],{}," leaks across SSR requests; the out-of-scope neighbour of this article.",[128,2921,2922,2926,2927,2929],{},[27,2923,2925],{"href":2418,"rel":2924},[31],"Performance.measureUserAgentSpecificMemory() — MDN"," — Chromium-only and experimental; rejects with ",[14,2928,2432],{}," unless the document is cross-origin isolated.",[128,2931,2932,2936],{},[27,2933,2935],{"href":2440,"rel":2934},[31],"Performance.memory — MDN"," — deprecated and non-standard; named here only to rule it out as a fallback.",[128,2938,2939,133,2943,2945,2946,2948],{},[27,2940,2942],{"href":1555,"rel":2941},[31],"Pinia: Actions — Pinia docs",[14,2944,1543],{}," binds to the component that registers it unless ",[14,2947,1567],{}," is passed as the second argument.",[128,2950,2951,133,2955,2957],{},[27,2952,2954],{"href":1550,"rel":2953},[31],"Pinia: State — Pinia docs",[14,2956,1539],{}," binds to the component that registers it unless detached.",[128,2959,2960,2964,2965,2967],{},[27,2961,2963],{"href":1856,"rel":2962},[31],"RFC 0041: Reactivity effectScope — vuejs\u002Frfcs"," — design intent behind ",[14,2966,173],{}," and scope-bound disposal.",[128,2969,2970,133,2975,2977,2978,2981],{},[27,2971,2974],{"href":2972,"rel":2973},"https:\u002F\u002Fvitest.dev\u002Fapi\u002Fvi.html",[31],"Vitest API: vi — Vitest docs",[14,2976,2411],{}," on a global, and the timer globals ",[14,2979,2980],{},"useFakeTimers"," replaces.",[128,2983,2984,133,2989,2991,2992,93,2994,2996,2997,2999],{},[27,2985,2988],{"href":2986,"rel":2987},"https:\u002F\u002Fvitest.dev\u002Fguide\u002Fenvironment",[31],"Vitest: Test Environment — Vitest docs",[14,2990,2377],{}," is the default; ",[14,2993,2385],{},[14,2995,2388],{}," supplies the DOM that ",[14,2998,2381],{}," needs.",[128,3001,3002,3006,3007,3009],{},[27,3003,3005],{"href":783,"rel":3004},[31],"Vue core: reactivity\u002Fwatch.ts — vuejs\u002Fcore"," — watcher cleanups are registered as the effect's ",[14,3008,787],{}," hook, so they also run when the watcher stops.",[128,3011,3012,3016],{},[27,3013,3015],{"href":2151,"rel":3014},[31],"Vue DevTools: Features — Vue DevTools docs"," — the documented panel list; no memory or retention panel among them.",[128,3018,3019,133,3024,3026,3027,3030,3031,3034],{},[27,3020,3023],{"href":3021,"rel":3022},"https:\u002F\u002Ftest-utils.vuejs.org\u002Fapi\u002F",[31],"Vue Test Utils: API reference — Vue Test Utils docs",[14,3025,2381],{},", and the root wrapper's ",[14,3028,3029],{},"unmount()"," firing the component's ",[14,3032,3033],{},"unmounted"," hook.",[128,3036,3037,3041,3042,2549,3045,3047],{},[27,3038,3040],{"href":2487,"rel":3039},[31],"Vue.js KeepAlive guide: lifecycle of a cached instance — Vue docs"," — a cached component is deactivated rather than unmounted; ",[14,3043,3044],{},"onActivated",[14,3046,2491],{}," are the hooks for that state.",[128,3049,3050,133,3054,136,3056,136,3059,594],{},[27,3051,3053],{"href":1694,"rel":3052},[31],"Vue.js Reactivity API: Advanced — Vue docs",[14,3055,173],{},[14,3057,3058],{},"getCurrentScope",[14,3060,170],{},[128,3062,3063,3067],{},[27,3064,3066],{"href":59,"rel":3065},[31],"Vue.js SSR guide: cross-request state pollution — Vue docs"," — singleton state reused across requests on a long-running server process.",[128,3069,3070,3074,3075,594],{},[27,3071,3073],{"href":29,"rel":3072},[31],"Vue.js Watchers guide — Vue docs"," — auto-disposal of synchronous watchers, async-callback trap, ",[14,3076,176],{},[128,3078,3079,3083],{},[27,3080,3082],{"href":2054,"rel":3081},[31],"VueUse: useEventListener — VueUse docs"," — listener registered on mount, removed automatically on unmount.",[128,3085,3086,3090,3091,3094],{},[27,3087,3089],{"href":2067,"rel":3088},[31],"VueUse: useIntervalFn — VueUse docs"," — timer composable signature and its ",[14,3092,3093],{},"Pausable"," controls.",[128,3096,3097,3101,3102,3104,3105,3107],{},[27,3098,3100],{"href":2075,"rel":3099},[31],"VueUse: useIntervalFn source — vueuse\u002Fvueuse"," — hands ",[14,3103,2081],{}," to ",[14,3106,2085],{},", which is what clears the interval automatically.",[128,3109,3110,3114,3115,594],{},[27,3111,3113],{"href":1523,"rel":3112},[31],"WeakMap.prototype.set — MDN"," — WeakMap keys must be objects or non-registered symbols; anything else throws a ",[14,3116,1532],{},[128,3118,3119,3123],{},[27,3120,3122],{"href":2470,"rel":3121},[31],"Why Sentry cannot detect page crashes — getsentry\u002Fsentry-javascript issue #5280"," — a maintainer's explanation that an OOM kill stops the SDK along with the page.",[3125,3126,3127],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":370,"searchDepth":420,"depth":420,"links":3129},[3130,3131,3132,3133,3134,3135,3136,3137,3138,3139,3140],{"id":76,"depth":394,"text":77},{"id":180,"depth":394,"text":181},{"id":347,"depth":394,"text":348},{"id":759,"depth":394,"text":760},{"id":1201,"depth":394,"text":1202},{"id":1659,"depth":394,"text":1660},{"id":2037,"depth":394,"text":2038},{"id":2144,"depth":394,"text":2145},{"id":2476,"depth":394,"text":2477},{"id":2495,"depth":394,"text":2496},{"id":2809,"depth":394,"text":2810},"2026-08-12","Vue 3 auto-disposes the reactive effects it owns, so the leaks that actually bite live in the manual timers, listeners, subscriptions, and long-lived references it never sees. A field guide to where Vue 3 memory leaks come from — and the one rule that prevents all of them.","md",null,{},"\u002Farticles\u002Fvue-3-memory-leaks",26,{"title":5,"description":3142},"articles\u002Fvue-3-memory-leaks",[3151,3152,3153,3154],"Vue.js","Composition API","Reactivity","Performance","3JKS3oZteVPugIDApGybj143agL1KzXSgadL80A4_4s",1786991252935]