Memory leaks

Client-side leaks

The eleven ways a long-lived browser tab keeps hold of unmounted components — listeners, timers, observers, third-party instances, orphaned effects and growing global state — with the Nuxt and VueUse fixes.

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.

The catalogue

#PatternFix
1window / 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
2setInterval / requestAnimationFrame loops without a clearuseIntervalFn, useRafFn, or clearInterval / cancelAnimationFrame in onBeforeUnmount
3IntersectionObserver / ResizeObserver / MutationObserver never disconnecteduseIntersectionObserver, useResizeObserver, useMutationObserver, or observer.disconnect() on unmount
4Third-party instances (charts, maps, editors, players) never destroyed → detached DOM treesinstance.destroy() / dispose() in onBeforeUnmount; markRaw the instance so Vue never proxies it
5Effects 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
6Ever-growing global state: useState or Pinia arrays that only grow; an async-data cache that accumulates over a long sessionEvict 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 maxSet max, or exclude the heavy pages with include / exclude
8nuxtApp.hook('page:finish', …) or an event-bus subscription inside a component without unsubscribinghook() returns an unsubscribe — call it in onUnmounted; or move the subscription to a plugin, where it is registered once per session
9router.afterEach / beforeEach registered inside a componentRegister in a plugin; or keep the returned remover and call it on unmount
10URL.createObjectURL without revokeObjectURL; Web Workers not terminated; BroadcastChannel / WebSocket left openRevoke, terminate(), close() on unmount
11DOM 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

How it works

1, 2, 3 — listeners, timers, observers

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>

Without VueUse, one AbortController retires any number of listeners at once:

app/composables/useHotkeys.ts
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())
}
Gotcha· `onUnmounted` never runs if the component never mounted

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.

4 — third-party instances and detached DOM

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>

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.

5 — effects created outside a scope

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))
  })
})

6, 7, 8 — state and caches that only grow

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.

app/pages/reports/[id].vue
<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:

app/app.vue
<template>
  <NuxtLayout>
    <!-- without `max`, every page the user ever visits stays mounted in memory -->
    <NuxtPage :keepalive="{ max: 6, exclude: ['ReportDetail'] }" />
  </NuxtLayout>
</template>
Gotcha· `nuxtApp.hook()` inside a component subscribes for the whole session

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.

Exercise

Exercise
  • Build a page with the leaky 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.
  • Take a heap snapshot, filter by Detached, and find the detached <header> subtree. Open Retainers and follow the chain to window's listener list.
  • Replace the three side effects with useEventListener, useIntervalFn and useResizeObserver, repeat the 20 navigations, and confirm the three counters return to baseline after "Collect garbage".
  • Add a chart component without destroy(), then with it, and compare the delta in retained size per navigation.

Be able to say

Be able to say· What leaks in a Nuxt app across client-side navigations, and how do you make a composable leak-proof?

"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."