# JavaScript memory leak

> A leaking heap grows until the tab janks or crashes. Find the listeners, timers and subscriptions that are never cleaned up and release them.

_Category: Performance · Detector `memory-leak` · Severity: error_

A memory leak means the heap grows every time you navigate or interact and never comes back down, until long sessions jank, stutter, and eventually crash the tab. It is one of the hardest bugs to catch in review because it only shows up over time — the happy-path click looks fine. AI-generated effects and subscriptions are a frequent source: the setup is written, the cleanup is forgotten.

## Symptoms

- The page gets slower the longer it stays open
- Heap usage climbs across route changes and never recovers after GC
- Eventually a tab crash or "Aw, Snap" on long sessions
- DevTools Memory timeline shows a rising staircase of retained objects

## How VibeCheck detects it

The `memory-leak` detector flags this live in the browser and reports it to the widget's Problems list — and to your coding agent over MCP.

- **Issue string:** `Potential memory leak (27% growth)`
- **Threshold:** > 10% heap growth over 30s without GC recovery (warning), > 25% (error)

## Root causes

- `addEventListener` / `setInterval` / `setTimeout` without a matching cleanup
- Subscriptions (WebSocket, SSE, store, RxJS) never unsubscribed on unmount
- State that only grows — arrays/maps/caches that append and never evict
- Detached DOM nodes still referenced by a closure or long-lived variable

## The fix

For every side effect that acquires a resource, register a teardown that releases it when the component unmounts or the effect re-runs. Remove event listeners, clear timers, close connections, and unsubscribe. Bound any long-lived cache so it evicts instead of growing forever.

### Steps

1. Audit every effect/lifecycle hook that adds a listener, timer, or subscription
2. Return or register a cleanup that removes exactly what was added
3. Bound caches and growing arrays with an eviction policy (e.g. LRU or max size)

_The shape of every fix: acquire → release_

```js
const id = setInterval(tick, 1000)
const onResize = () => layout()
window.addEventListener('resize', onResize)

// teardown — runs on unmount / effect re-run
clearInterval(id)
window.removeEventListener('resize', onResize)
```

## Framework-specific fixes

### React

Return a cleanup function from `useEffect`. It runs on unmount and before every re-run, so the listener/timer/subscription is always released.

```tsx
useEffect(() => {
  const id = setInterval(tick, 1000)
  const onResize = () => setW(window.innerWidth)
  window.addEventListener('resize', onResize)
  const sub = source.subscribe(setData)
  return () => {
    clearInterval(id)
    window.removeEventListener('resize', onResize)
    sub.unsubscribe()
  }
}, [])
```

[React docs](https://react.dev/reference/react/useEffect#connecting-to-an-external-system)

### Next.js

Same `useEffect` cleanup rules — but leaks in Next are most visible across client-side route changes, where a component unmounts without releasing a global listener. Also guard against effects running twice under React Strict Mode by making cleanup idempotent.

```tsx
'use client'
useEffect(() => {
  const ws = new WebSocket(url)
  ws.onmessage = onMessage
  return () => ws.close() // fires on navigation away
}, [url])
```

### Vue

Register teardown in `onUnmounted` (Composition API) or `beforeUnmount` (Options API). `onScopeDispose` works inside composables.

```vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
let id: number
const onResize = () => (w.value = window.innerWidth)
onMounted(() => {
  id = window.setInterval(tick, 1000)
  window.addEventListener('resize', onResize)
})
onUnmounted(() => {
  clearInterval(id)
  window.removeEventListener('resize', onResize)
})
</script>
```

[Vue docs](https://vuejs.org/api/composition-api-lifecycle.html#onunmounted)

### Svelte

In Svelte 5, return a cleanup from `$effect`; in Svelte 4 use `onDestroy`. Either releases the resource when the component is torn down.

```svelte
<script lang="ts">
  $effect(() => {
    const id = setInterval(tick, 1000)
    const onResize = () => (w = window.innerWidth)
    window.addEventListener('resize', onResize)
    return () => {
      clearInterval(id)
      window.removeEventListener('resize', onResize)
    }
  })
</script>
```

[Svelte docs](https://svelte.dev/docs/svelte/lifecycle-hooks)

### Vanilla JS

Keep a reference to every listener/timer you add and release them when the view is destroyed. `AbortController` makes multi-listener cleanup a one-liner.

```js
const ac = new AbortController()
window.addEventListener('resize', onResize, { signal: ac.signal })
document.addEventListener('scroll', onScroll, { signal: ac.signal })
// teardown removes both at once:
ac.abort()
```

## FAQ

### How do I confirm it is really a leak and not normal growth?

Take heap snapshots in DevTools Memory before and after repeating an action (e.g. navigate away and back a few times). If retained size keeps climbing and never drops after GC, it’s a leak. VibeCheck watches for exactly this: sustained growth with no recovery.

### React Strict Mode runs my effect twice — is that a leak?

No, that is intentional in development to surface missing cleanup. If your cleanup is correct and idempotent, the double-invoke is harmless and doesn’t happen in production.

### What are detached DOM nodes?

Nodes removed from the document but still referenced by JavaScript (a closure, a cached array, an event handler). They can’t be garbage-collected. Drop the reference when you remove the node.

## Related problems

- [Excessive DOM size](https://vibecheck.wcgw.fun/fix/excessive-dom-size.md) — Performance
- [Console.log spam](https://vibecheck.wcgw.fun/fix/console-log-spam.md) — Performance
- [Heavy dependencies](https://vibecheck.wcgw.fun/fix/heavy-dependencies.md) — Performance
- [Long tasks blocking the main thread](https://vibecheck.wcgw.fun/fix/long-tasks.md) — Performance

---

Fix guide from VibeCheck — https://vibecheck.wcgw.fun/fix/memory-leak. Full site index for LLMs: https://vibecheck.wcgw.fun/llms.txt
