JavaScript & the runtime

Memory and performance in JavaScript

How garbage collection decides what lives, the leak patterns that survive it, finding retainers with heap snapshots, and the performance model — Big-O in practice, hot paths, long tasks, and measuring before optimising.

"How would you find a memory leak" and "this list is slow, what do you do" are senior questions because they test method: knowing what the garbage collector can and cannot free, knowing the handful of patterns that leak, and measuring with the right tool before changing code. The memory-leaks section covers Nuxt and SSR specifics; this page is the language-level model.

Know

  • Garbage collection is about reachability. Anything reachable from a root (the global object, the current stack, active closures, the DOM tree, module scopes) stays; everything else is collected. V8 uses a young generation (fast scavenges for short-lived objects) and an old generation (mark-sweep-compact, incremental and concurrent). You cannot force a collection, and a leak is never "GC failing"; it is something still referencing what you thought was gone.
  • The leak catalogue: timers and intervals never cleared; event listeners on long-lived targets (window, document, a store) never removed; closures retaining large scopes; detached DOM nodes still referenced from JavaScript; unbounded caches (Map or array used as a cache with no eviction); accidental globals; subscriptions (observers, sockets, stores) not disposed; console.log of objects in DevTools keeping them alive; growing arrays in module scope on a server.
  • Prevention patterns: addEventListener(..., { signal }) with one AbortController per owner; onScopeDispose/onUnmounted for everything registered in a component; WeakMap for object-keyed caches; bounded LRU caches with size or TTL; clearInterval in cleanup; disconnect() on observers; avoid module-level mutable state on servers.
  • Finding a leak: DevTools Memory panel, take a heap snapshot, perform the action several times, take another, use comparison view sorted by delta, inspect retainers (the path from a root to the object); the allocation timeline shows what is allocated during an action and whether it is freed. performance.measureUserAgentSpecificMemory() for RUM, process.memoryUsage() and --heap-snapshot-near-heap-limit in Node.
  • Performance model: JavaScript cost is usually one of algorithmic complexity, allocation churn, or blocking the main thread. Big-O in practice: nested find/includes inside a loop is O(n²) and a Map index makes it O(n); Set.has beats Array.includes; string concatenation in a loop is fine (ropes), but JSON.parse/stringify and structuredClone of large objects are not free.
  • Engine-friendly code: objects with stable shapes (same properties in the same order, no delete on hot objects, initialise fields in the constructor), monomorphic call sites, avoid arguments and try/catch in the hottest inner loops only when profiling says so. Micro-optimisations come last.
  • Main-thread budget: a task over 50 ms is a long task; INP measures input-to-paint delay. Chunk work with scheduler.yield()/setTimeout, move CPU work to workers, debounce and throttle input handlers, virtualise long lists, and memoise derived data (a computed in Vue) instead of recomputing in render.
  • Measure first: performance.mark/measure and performance.now() for code, the Performance panel's flame chart for main-thread time, Lighthouse and RUM (web-vitals) for user impact. Benchmark with repeated runs and a warm-up; a single console.time is noise.

How it works

The classic leaks and their fixes, side by side:

app/composables/useTicker.ts
export function useTicker(onTick: () => void) {
  // ✗ never cleared: the interval (and the closure, and the component) live forever
  // setInterval(onTick, 1000)

  // ✓ owned by the component scope
  const { pause } = useIntervalFn(onTick, 1000)
  onScopeDispose(pause)
}
app/utils/listeners.ts
export function bindShortcuts(handlers: Record<string, () => void>) {
  const controller = new AbortController()
  // One signal for every listener: aborting removes them all, including ones added later.
  window.addEventListener('keydown', e => handlers[e.key]?.(), { signal: controller.signal })
  window.addEventListener('blur', () => console.debug('lost focus'), { signal: controller.signal })
  return () => controller.abort()
}
app/utils/lru.ts
// A cache with a bound. Map preserves insertion order, so the first key is the least recently inserted.
export class LRU<K, V> {
  #map = new Map<K, V>()
  constructor(private readonly max = 100) {}
  get(key: K): V | undefined {
    const value = this.#map.get(key)
    if (value === undefined) return undefined
    this.#map.delete(key) // move to the end: most recently used
    this.#map.set(key, value)
    return value
  }
  set(key: K, value: V) {
    this.#map.delete(key)
    this.#map.set(key, value)
    if (this.#map.size > this.max) this.#map.delete(this.#map.keys().next().value!) // evict the oldest
  }
}

// Object-keyed memo that cannot leak: entries die with their keys.
const layoutCache = new WeakMap<HTMLElement, DOMRect>()
export function measure(el: HTMLElement) {
  return layoutCache.get(el) ?? layoutCache.set(el, el.getBoundingClientRect()).get(el)!
}

Turning an O(n²) render helper into O(n), and proving it:

bench.ts
function joinSlow(orders: Order[], users: User[]) {
  return orders.map(o => ({ ...o, user: users.find(u => u.id === o.userId) })) // n × m
}
function joinFast(orders: Order[], users: User[]) {
  const byId = new Map(users.map(u => [u.id, u] as const))
  return orders.map(o => ({ ...o, user: byId.get(o.userId) })) // n + m
}

for (const fn of [joinSlow, joinFast]) {
  fn(orders, users) // warm-up
  performance.mark('start')
  for (let i = 0; i < 10; i++) fn(orders, users)
  performance.mark('end')
  console.log(fn.name, performance.measure(fn.name, 'start', 'end').duration / 10, 'ms')
}
Gotcha· A WeakMap is not a magic cache

WeakMap keys must be objects and the map is not iterable, so it cannot implement LRU or size limits, and it cannot cache by string key. It is right for "attach data to this object for as long as the object lives"; for everything else use a bounded Map.

Gotcha· Optimising the flame chart's wrong bar

The widest bar in a profile is often "(program)" or GC, caused by allocation churn elsewhere. Read the profile bottom-up, find your code that allocates or recomputes, fix that, re-measure. Guessing at micro-optimisations without a profile is how teams spend a sprint saving two milliseconds.

Exercise

Exercise
  • Build a page that mounts and unmounts a component with an uncleaned setInterval fifty times; take heap snapshots before and after and find the retainer path. Fix it and confirm the delta is flat.
  • Implement the LRU class and a test that inserts max + 1 entries and asserts which key was evicted after a get reorders.
  • Profile the slow join with 5,000 orders and 5,000 users, then the fast one; record both numbers and the flame chart shape.

Be able to say

Be able to say· How would you find and fix a memory leak in a frontend application?

"Reproduce it as a repeatable action: open and close a view twenty times. Take a heap snapshot before and after, use the comparison view to see which constructors grew, and follow the retainers of one of those objects back to a root; the path names the culprit, typically a listener on window, an interval, a closure holding a large scope, a detached DOM node or a cache without bounds. The fix follows the pattern: remove listeners with an AbortSignal or in onScopeDispose, clear timers, cap caches or key them weakly, and stop keeping per-request data at module level on the server. Then I repeat the snapshot comparison to prove the growth is gone, and if it matters I add a test that measures heap growth over repetitions."

Be able to say· A list view is slow. What is your process?

"Measure before touching code: a Performance profile of the interaction to see whether the time is in JavaScript, layout or paint, and how long the tasks are. If it is JavaScript, I look for algorithmic problems first, nested lookups that a Map index turns linear, derived data recomputed in render that a computed would cache, and deep reactivity on large arrays that shallowRef avoids. If it is rendering, I check for layout thrashing, too many DOM nodes, and virtualise the list. Long tasks get chunked or moved to a worker so input stays responsive. Then I re-measure the same interaction, because the goal is a number moving, not a feeling."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.