Timers and event listeners
Timers and event listeners have to be cleared because each of them is a live reference to the context of its callback, and GC only frees unreachable objects. setTimeout, setInterval and addEventListener create asynchronous tasks that live separately from the main JS execution stack, and while such a task is active, everything its callback touches is considered reachable.
Theory
TL;DR
- A timer or a listener holds a closure, and the closure holds every variable the callback touches.
- While that reference is alive, GC treats the objects as reachable and frees nothing.
- Removing a DOM element does not remove its listener: the node becomes a detached DOM node and stays on the heap.
- In a SPA this accumulates, because the page never reloads until the tab is closed.
- Besides memory, CPU suffers: hundreds of live intervals and duplicate firings from old listeners.
- The cure is simple:
clearInterval/clearTimeout,removeEventListener,unsubscribe,return () => cleanup()inuseEffect.
Quick example
function startTimer() {
const bigData = new Array(1e6).fill('x');
setInterval(() => console.log(bigData.length), 1000);
}
startTimer();Here bigData is never released:
- the interval lives forever;
- the closure holds a reference to
bigData; - GC considers
bigData"live", and memory grows.
It has to be cleared:
const id = setInterval(tick, 1000);
clearInterval(id);What exactly retains the memory
With event listeners the story is the same:
const button = document.getElementById('btn');
button.addEventListener('click', () => console.log('clicked'));
button.remove(); // the element is gone, the listener is notNow the button element has been removed from the DOM, but the callback is still kept in memory, because the event system still holds a reference to it. GC cannot collect the node, because a registered handler is still "looking at" it. Nodes like this are exactly what a heap snapshot shows as Detached DOM nodes.
The fix:
button.removeEventListener('click', handler);Note that an anonymous arrow function cannot be removed at all, because removeEventListener compares function references. The handler has to be stored in a variable.
| Object | Reference | Why it is not collected |
|---|---|---|
| Timer | Callback (closure) | Holds a reference to every variable in the closure |
| Listener | Callback plus the DOM element | They live until removeEventListener is called |
WebSocket, RxJS, setInterval | An active subscription | Holds the whole subscription context |
React useEffect | No cleanup | The effect is never cancelled, the references survive |
GC treats all of this as reachable precisely because it has live references to the callbacks.
Why this is especially critical in a SPA
In a SPA the page never reloads. Anything that is not cleaned up lives until the user closes the tab.
For example:
- you navigate between pages;
- components mount and unmount;
- if timers and listeners are not removed, every "old" component keeps existing in memory and the garbage piles up.
After 30 minutes of using a React application you can see:
- hundreds of active intervals;
- dozens of "zombie" listeners;
- heap memory that grows and never returns to the baseline.
How to clean up properly
Timers:
useEffect(() => {
const id = setInterval(() => { /* ... */ }, 1000);
return () => clearInterval(id);
}, []);Event listeners:
useEffect(() => {
const handler = () => console.log('scroll');
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);Subscriptions (RxJS, WebSocket, custom events):
useEffect(() => {
const sub = stream$.subscribe(handleValue);
return () => sub.unsubscribe();
}, []);For listeners that need to be removed as a batch, AbortController is handy: every subscription sharing one signal is detached by a single abort() call.
What happens if you do not clean up
| Consequence | What happens |
|---|---|
| Growing memory | Callback closures retain large objects |
| UI lag | The event loop is clogged with active callbacks |
| Duplicate calls | Old listeners fire alongside the new ones |
| Rising CPU | Too many active intervals run in parallel |
| "Out of memory" | The browser or the tab crashes |
| "Zombie components" | Old React components keep living and running their effects |
The tools that make this visible:
- Chrome DevTools, Memory, Heap snapshot: look for detached DOM nodes and
Timeout/EventListenerobjects. - Performance, Record: watch Long tasks and the memory graph.
- React DevTools Profiler: confirm that components really unmount.
- Sentry Performance: heap growth, leaks and FPS degradation in production.
Summary:
| What to remember | Why |
|---|---|
setTimeout / setInterval must be cleared | Otherwise the callback stays alive |
addEventListener must be removed | Otherwise the DOM node and the function are never freed |
In React, always return () => cleanup() in useEffect | It prevents "zombie" effects |
| A SPA has no memory "restart" | Everything piles up until you clear it by hand |
| Memory leaks mean lag plus crashes | GC cannot collect "live" references |
A timer or a listener is like an open tab in memory. If you never close it, you end up with hundreds of them and the browser dies.
Common mistakes
- Subscribing with an anonymous function.
addEventListener('click', () => ...)can never be removed:removeEventListenercompares function references, so the handler has to live in a variable. - Assuming that
element.remove()also removes the listeners. It does not; the node stays in memory as a detached DOM node for as long as the handler lives. - Not keeping the timer id. A
setInterval(fn, 1000)whose id was thrown away can no longer be stopped from code. - Removing a listener from a different element or with a different event type.
removeEventListeneronly works on an exact match of target, event type and options (capture, for instance). - Forgetting the cleanup in a
useEffectwith an empty dependency array. Such an effect looks "one time", but it runs on every mount of the component. - Ignoring subscriptions that do not look like listeners.
IntersectionObserver,ResizeObserver,MutationObserverand WebSocket also have to be stopped explicitly:disconnect(),close(),unsubscribe().
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.