A single-page app is one JavaScript context that lives as long as the tab, and route changes mount and unmount components underneath it hundreds of times in a working session. Every leak on this page has the same shape: something that outlives the component — the window, the router, the nuxtApp, a module-scope variable — is still holding a reference to it, so the component, its reactive effects and usually a chunk of detached DOM can never be collected. Internal tools are where this hurts, because nobody reloads a dashboard they keep open all day.
| # | Pattern | Fix |
|---|---|---|
| 1 | window / document listeners added in onMounted without onUnmounted (scroll, resize, keydown, matchMedia) | VueUse useEventListener (auto-cleans via tryOnScopeDispose), or addEventListener(type, fn, { signal }) with an AbortController aborted on unmount |
| 2 | setInterval / requestAnimationFrame loops without a clear | useIntervalFn, useRafFn, or clearInterval / cancelAnimationFrame in onBeforeUnmount |
| 3 | IntersectionObserver / ResizeObserver / MutationObserver never disconnected | useIntersectionObserver, useResizeObserver, useMutationObserver, or observer.disconnect() on unmount |
| 4 | Third-party instances (charts, maps, editors, players) never destroyed → detached DOM trees | instance.destroy() / dispose() in onBeforeUnmount; markRaw the instance so Vue never proxies it |
| 5 | Effects created outside a scope: watch inside router.afterEach, inside setTimeout, or after an await in a plain (non-<script setup>) setup() | Create effects synchronously in setup; otherwise effectScope() + stop(), and onScopeDispose for manual cleanup |
| 6 | Ever-growing global state: useState or Pinia arrays that only grow; an async-data cache that accumulates over a long session | Evict deliberately; clearNuxtData(keys); lean on experimental.purgeCachedData (on by default) and do not defeat it with a getCachedData that never expires |
| 7 | <NuxtPage keepalive> / <KeepAlive> without max | Set max, or exclude the heavy pages with include / exclude |
| 8 | nuxtApp.hook('page:finish', …) or an event-bus subscription inside a component without unsubscribing | hook() returns an unsubscribe — call it in onUnmounted; or move the subscription to a plugin, where it is registered once per session |
| 9 | router.afterEach / beforeEach registered inside a component | Register in a plugin; or keep the returned remover and call it on unmount |
| 10 | URL.createObjectURL without revokeObjectURL; Web Workers not terminated; BroadcastChannel / WebSocket left open | Revoke, terminate(), close() on unmount |
| 11 | DOM element references stored in reactive state or module scope (detached nodes) | useTemplateRef() / a template ref and let Vue manage it; never stash querySelector results in a global |
All three are the same bug and have the same shape of fix. The manual version is fine as long as the teardown is symmetric; the reason to prefer VueUse in a layer is that the composable registers its own cleanup on the active scope, so a consumer cannot forget.
<script setup lang="ts">
const compact = ref(false)
onMounted(() => {
// three owners that outlive this component, zero teardown
window.addEventListener('scroll', () => { compact.value = window.scrollY > 64 })
setInterval(() => refreshBadge(), 5_000)
new ResizeObserver(measure).observe(document.body)
})
</script>
<script setup lang="ts">
const compact = ref(false)
// each of these registers cleanup on the current scope and stops on unmount
useEventListener(window, 'scroll', () => { compact.value = window.scrollY > 64 })
useIntervalFn(() => refreshBadge(), 5_000)
useResizeObserver(document.body, measure)
</script>
Without VueUse, one AbortController retires any number of listeners at once:
export function useHotkeys(handler: (e: KeyboardEvent) => void) {
const controller = new AbortController()
const { signal } = controller
if (import.meta.client) {
window.addEventListener('keydown', handler, { signal })
window.addEventListener('blur', reset, { signal })
}
// works in a component AND in any effectScope — nothing is component-specific
onScopeDispose(() => controller.abort())
}
Setup runs on the server too, so a listener registered directly in setup() (rather than in onMounted) on a component that is later <ClientOnly>-skipped, or a subscription in a component inside a <Suspense> boundary that errors before mounting, has no matching teardown. Register browser side effects in onMounted, or use onScopeDispose, which fires whenever the owning scope stops — mounted or not.
This is the biggest leak by bytes: a chart or map instance holds its canvas, its data arrays and a whole detached DOM subtree, and Vue's reactive proxy over it makes the retainer chain even harder to read in a snapshot.
<script setup lang="ts">
const el = ref<HTMLElement>()
// ⚠ deep-reactive proxy over a library instance, and never destroyed
const chart = ref<Chart | null>(null)
onMounted(() => {
chart.value = new Chart(el.value!, { data: props.data })
})
</script>
<template><div ref="el" /></template>
<script setup lang="ts">
import { markRaw, shallowRef } from 'vue'
const el = useTemplateRef<HTMLElement>('el')
// shallowRef + markRaw: Vue stores the instance, it never proxies its internals
const chart = shallowRef<Chart | null>(null)
onMounted(() => {
chart.value = markRaw(new Chart(el.value!, { data: props.data }))
})
onBeforeUnmount(() => {
chart.value?.destroy() // releases canvas, listeners and the detached subtree
chart.value = null
})
</script>
<template><div ref="el" /></template>
markRaw is not itself the leak fix — destroy() is. It matters because a reactive proxy of a library instance makes every internal object reachable through Vue's dependency graph, costs measurable CPU on every access, and turns the snapshot's Retainers view into noise.
watch, watchEffect and computed register themselves on the active effect scope. Inside <script setup> that is the component's scope, and the compiler restores it across top-level await for you. Inside a router guard, a setTimeout callback, or a plain async setup() after its first await, there is no active scope: the effect is created, subscribes to whatever reactive source it touches, and nothing will ever stop it.
export default defineNuxtPlugin((nuxtApp) => {
const router = useRouter()
const user = useState<User | null>('auth:user')
router.afterEach(() => {
// a new watcher on every navigation, none of them owned by anything
watch(user, u => track('user', u?.id))
})
})
import { effectScope } from 'vue'
export default defineNuxtPlugin((nuxtApp) => {
const user = useState<User | null>('auth:user')
const scope = effectScope(true)
// one watcher for the tab, created once, in a scope I can stop
scope.run(() => watch(user, u => track('user', u?.id)))
// the guard's remover is kept and called, so HMR does not stack guards
const stopGuard = useRouter().afterEach(to => track('page', to.path))
if (import.meta.hot) {
import.meta.hot.dispose(() => { stopGuard(); scope.stop() })
}
})
useState and Pinia live for the whole tab, so a store action that pushes into an array on every route visit is a leak with no listener involved. useAsyncData results live in nuxtApp.payload.data; Nuxt 4 ships experimental.purgeCachedData enabled by default, which drops a key's cached data once no component is using it any more — which is exactly what a custom getCachedData that always returns the previous value defeats. Where you own the lifecycle explicitly, clearNuxtData(keys) is the eviction call.
<script setup lang="ts">
const route = useRoute()
const key = computed(() => `report:${route.params.id}`)
const { data } = await useAsyncData(key.value, () => $fetch(`/api/reports/${route.params.id}`))
// this page's payload entry can be large; drop it when the user leaves
onScopeDispose(() => clearNuxtData(key.value))
</script>
And <KeepAlive> is an intentional leak with a bound — the bound is not optional:
<template>
<NuxtLayout>
<!-- without `max`, every page the user ever visits stays mounted in memory -->
<NuxtPage :keepalive="{ max: 6, exclude: ['ReportDetail'] }" />
</NuxtLayout>
</template>
nuxtApp.hook('page:finish', …) in a component looks harmless because nuxtApp "is the app". But the nuxtApp lives as long as the tab, so each mount of that component adds a callback that retains the component instance. hook() returns an unsubscribe function: capture it and call it in onUnmounted, or move the subscription into a plugin where it happens once per session. The same applies to router.beforeEach, mitt/event-bus on(), and any useNuxtApp().$bus-style API you ship in a layer.
StickyHeader above. Open DevTools → Performance Monitor, navigate away and back 20 times, and watch JS heap size, DOM nodes and Event listeners ratchet up and never come down.Detached, and find the detached <header> subtree. Open Retainers and follow the chain to window's listener list.useEventListener, useIntervalFn and useResizeObserver, repeat the 20 navigations, and confirm the three counters return to baseline after "Collect garbage".destroy(), then with it, and compare the delta in retained size per navigation."Anything owned by something longer-lived than the component: a window or document listener added in onMounted with no onUnmounted, an interval or rAF loop, an unobserved IntersectionObserver, a chart or map instance that was never destroyed and keeps a detached DOM tree alive, a nuxtApp.hook or router guard registered in a component, and effects created where there is no active scope — inside router.afterEach or a setTimeout. Then there is the slow class: useState or Pinia arrays that only grow, and <KeepAlive> without max. To make a composable leak-proof I create every effect synchronously so the caller's scope owns it, register teardown with onScopeDispose or tryOnScopeDispose rather than onUnmounted so it also works outside a component, return the unsubscribe function from anything I subscribe to, and prefer VueUse's useEventListener-style wrappers so a consumer cannot forget. I verify it with the navigate-away-and-back-20-times test and a snapshot filtered by Detached."
Server-side leaks
The thirteen ways a Nuxt/Nitro server holds on to request-lifetime objects, what each one looks like in production, and the before/after for the ones that actually happen.
Detecting and measuring
How to prove a leak instead of guessing — health-route baselines, the Chrome DevTools snapshot workflow, production-safe Node flags, the navigate-20-times client test, and a memory watermark test for CI.