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
fetchthroughAbortController. - Do not keep references to detached DOM nodes, and do not capture large objects in closures without a reason.
- Caches:
WeakMap/WeakSetfor 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 handlingSIGTERM. FinalizationRegistryis 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
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.
const onClick = () => {};
button.addEventListener('click', onClick);
// ...
button.removeEventListener('click', onClick); // must be the very same callbackIn React, always do cleanup in useEffect:
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
const id = setInterval(tick, 1000);
// ...
clearInterval(id);In Node.js 20+ you can use an AbortSignal for timers:
const ac = new AbortController();
setTimeout(cb, 5000, { signal: ac.signal });
// ...
ac.abort();Unfinished fetches and streams
Cancel requests through AbortController and close your streams.
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():
elem.remove();
elemRef = null; // give the GC a chanceLarge closures
Do not capture large objects without a reason, pass the minimum into callbacks, and break references once you are done:
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.
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:
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:
useEffectshould return() => { ... }. - Do not store huge objects in
useRefor 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()inonModuleDestroy().
- Prisma:
- Streams: always
.destroy()or.end()on errors and timeouts. EventEmitter:removeListenerorremoveAllListenerswhen unloading.- Avoid uncontrolled
setInterval, prefer asetTimeoutloop with checks and a way to abort. - Handle shutdown signals:
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
--inspecttogether with Chrome DevTools, then heap snapshots.heapdumporclinicfor 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
setIntervalwithout an explicitclearInterval.
Common mistakes
- Removing a listener with a different function.
removeEventListener('click', () => {})removes nothing: it needs the same callback reference you added. - Forgetting
clearIntervalon 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
Mapwith no eviction grows for as long as the process lives. - Trying to use
WeakMapwith string keys. Weak collections accept objects only, primitive keys need an LRU. - Hoping
FinalizationRegistrywill replace explicit resource closing. The callback may never run. - Calling
global.gc()or expecting= nullto 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 readyA concise answer to help you respond confidently on this topic during an interview.