Suggest an editImprove this articleRefine the answer for “Timers and event listeners”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Because an active timer or a subscribed listener is a live reference: while it exists, the garbage collector considers the entire context of its callback reachable and cannot free a single byte.** `setTimeout`, `setInterval` and `addEventListener` create asynchronous tasks that live separately from the current call stack, and their callbacks hold, through the closure, everything they touch: large arrays, DOM nodes, component instances. Removing an element from the page is not enough, because the listener stays registered and the node turns into a detached DOM node. On top of that, old listeners fire alongside the new ones, which means duplicated requests and rising CPU usage. ```javascript useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); // without this the closure lives forever }, []); ``` **Key point:** `clearInterval`, `clearTimeout`, `removeEventListener` and `unsubscribe` are not hygiene, they are the only way to let GC reclaim the memory.Shown above the full answer for quick recall.Answer (EN)Image**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. | 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: ```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 | 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` / `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 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: `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()`.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.