Skip to main content

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() in useEffect.

Quick example

javascript
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:

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

What exactly retains the memory

With event listeners the story is the same:

javascript
const button = document.getElementById('btn'); button.addEventListener('click', () => console.log('clicked')); button.remove(); // the element is gone, the listener is not

Now 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:

javascript
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.

ObjectReferenceWhy it is not collected
TimerCallback (closure)Holds a reference to every variable in the closure
ListenerCallback plus the DOM elementThey live until removeEventListener is called
WebSocket, RxJS, setIntervalAn active subscriptionHolds the whole subscription context
React useEffectNo cleanupThe 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:

javascript
useEffect(() => { const id = setInterval(() => { /* ... */ }, 1000); return () => clearInterval(id); }, []);

Event listeners:

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

Subscriptions (RxJS, WebSocket, custom events):

javascript
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

ConsequenceWhat happens
Growing memoryCallback closures retain large objects
UI lagThe event loop is clogged with active callbacks
Duplicate callsOld listeners fire alongside the new ones
Rising CPUToo 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 / EventListener objects.
  • 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 rememberWhy
setTimeout / setInterval must be clearedOtherwise the callback stays alive
addEventListener must be removedOtherwise the DOM node and the function are never freed
In React, always return () => cleanup() in useEffectIt prevents "zombie" effects
A SPA has no memory "restart"Everything piles up until you clear it by hand
Memory leaks mean lag plus crashesGC 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: removeEventListener compares 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. removeEventListener only works on an exact match of target, event type and options (capture, for instance).
  • Forgetting the cleanup in a useEffect with 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, MutationObserver and WebSocket also have to be stopped explicitly: disconnect(), close(), unsubscribe().

Short Answer

Interview ready
Premium

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