Skip to main content

How to avoid memory leaks

You avoid memory leaks by managing references: no reference, no leak. Anything still reachable from the roots (global variables, closures, the DOM, caches) will never be collected, so the main practices are to shorten object lifetimes and to break links in time: handlers, timers, caches, DOM references.

Theory

TL;DR

  • The base principle is no reference, no leak. The GC only collects unreachable objects.
  • Remove event listeners with the same callback, clear timers, cancel fetch through AbortController.
  • Do not keep references to detached DOM nodes, and do not capture large objects in closures without a reason.
  • Caches: WeakMap / WeakSet for object keys, an LRU with a limit for string keys.
  • In React this is cleanup in useEffect, in Node.js it is closing connections, pools and streams plus handling SIGTERM.
  • FinalizationRegistry is only for "soft" cleanup, never build business logic on it.
  • Hunt leaks with tools: heap snapshots, allocation instrumentation, --trace-gc, RSS and heapUsed metrics.

Quick example

javascript
function mount(button) { const onClick = () => console.log('clicked'); const id = setInterval(tick, 1000); const ac = new AbortController(); button.addEventListener('click', onClick); fetch('https://api.example.com/data', { signal: ac.signal }); // Return a cleanup function: everything created must be torn down return () => { button.removeEventListener('click', onClick); // the very same callback clearInterval(id); ac.abort(); }; }

Typical sources of leaks and how to close them

Event handlers and subscriptions

The problem: dangling listeners hold both the DOM node and all the data captured in its closure.

javascript
const onClick = () => {}; button.addEventListener('click', onClick); // ... button.removeEventListener('click', onClick); // must be the very same callback

In React, always do cleanup in useEffect:

javascript
useEffect(() => { const onScroll = () => {}; window.addEventListener('scroll', onScroll); return () => window.removeEventListener('scroll', onScroll); }, []);

In NestJS and when working with EventEmitter, drop subscriptions in onModuleDestroy().

Timers and intervals

javascript
const id = setInterval(tick, 1000); // ... clearInterval(id);

In Node.js 20+ you can use an AbortSignal for timers:

javascript
const ac = new AbortController(); setTimeout(cb, 5000, { signal: ac.signal }); // ... ac.abort();

Unfinished fetches and streams

Cancel requests through AbortController and close your streams.

javascript
const c = new AbortController(); const res = await fetch(url, { signal: c.signal }); // ... c.abort();

Detached DOM nodes

Do not store references to nodes removed from the DOM, and clear DOM caches after remove():

javascript
elem.remove(); elemRef = null; // give the GC a chance

Large closures

Do not capture large objects without a reason, pass the minimum into callbacks, and break references once you are done:

javascript
let big = getHugeObject(); doWork(() => { /* we only use a small part */ }); big = null;

Global singletons and caches

Everything that lives in a module or in the global scope lives "forever". Bound its size, add eviction, or use weak references.

Caches without leaks

For object keys use WeakMap or WeakSet: they do not get in the way of garbage collection.

javascript
const memo = new WeakMap(); function compute(obj) { if (memo.has(obj)) return memo.get(obj); const val = heavy(obj); memo.set(obj, val); return val; }

For string or number keys weak collections do not apply, so use an LRU with a limit:

javascript
class LRU { constructor(limit = 500) { this.limit = limit; this.map = new Map(); } get(k) { const v = this.map.get(k); if (v) { this.map.delete(k); this.map.set(k, v); } return v; } set(k, v) { if (this.map.has(k)) this.map.delete(k); this.map.set(k, v); if (this.map.size > this.limit) { const oldest = this.map.keys().next().value; this.map.delete(oldest); // evict the oldest entry } } }

Separately: FinalizationRegistry is only good for soft cleanup of external caches and handles. Do not rely on the timing or the order of the call, and never build business logic on finalizers.

React and Next.js

  • Clean up every side effect: useEffect should return () => { ... }.
  • Do not store huge objects in useRef or in context unless you need to.
  • Avoid endless microtask chains of Promises: they delay both rendering and collection.
  • Unsubscribe from Observables and WebSockets in the cleanup function.
  • For long lists use virtualisation, so that fewer objects exist at the same time.

Node.js and NestJS

  • Close connections and pools:
    • Prisma: await prisma.$disconnect() on shutdown.
    • Redis, MongoDB, PostgreSQL: .quit(), .close(), .end() in onModuleDestroy().
  • Streams: always .destroy() or .end() on errors and timeouts.
  • EventEmitter: removeListener or removeAllListeners when unloading.
  • Avoid uncontrolled setInterval, prefer a setTimeout loop with checks and a way to abort.
  • Handle shutdown signals:
javascript
process.on('SIGTERM', async () => { await app.close(); // close the server, the database, the queues process.exit(0); });

For async work and queues: set timeouts and concurrency limits (semaphores or p-limit, for example), keep backpressure on sockets and streams, meaning do not read faster than you process, and do not hold large arrays of promises in memory, process them in chunks.

Tools for finding leaks, and guardrails

Browser

  • Memory, Heap snapshot: look for "Detached HTML elements" and their retainers.
  • Allocation instrumentation: shows who allocates and who retains.
  • Performance: long microtasks and frequent collections are a smell.

Node.js

  • --inspect together with Chrome DevTools, then heap snapshots.
  • heapdump or clinic for snapshots in production.
  • Collector logs: node --trace-gc app.js, for diagnostics, not for production.
  • Metrics: monitor RSS and heapUsed, and alert on a growth trend.

Guardrails in code and CI

  • Limits on cache and queue sizes.
  • An end to end test: "ran the scenario N times, memory stabilised".
  • Linter rules: ban anonymous listeners where removal is required, and ban setInterval without an explicit clearInterval.

Common mistakes

  • Removing a listener with a different function. removeEventListener('click', () => {}) removes nothing: it needs the same callback reference you added.
  • Forgetting clearInterval on unmount. The interval outlives the component and keeps its whole closure alive.
  • Keeping a reference to a node after remove(). The node becomes detached but stays alive, together with its entire subtree.
  • An unbounded cache in module scope. A global Map with no eviction grows for as long as the process lives.
  • Trying to use WeakMap with string keys. Weak collections accept objects only, primitive keys need an LRU.
  • Hoping FinalizationRegistry will replace explicit resource closing. The callback may never run.
  • Calling global.gc() or expecting = null to free memory instantly. The runtime decides when collection happens.
  • Hunting a leak by eye instead of by heap snapshots. Without comparing two snapshots and reading retainers you are only guessing.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.