skip to content

In the browser DOM, why does el.removeEventListener('click', () => save()) fail to remove a listener added with el.addEventListener('click', () => save()), and what exactly has to match for removal to succeed?

level: middleimportance: must knowfreq 66%

answer

  1. removal is by reference, not by shape
  2. two identical arrows are two objects
  3. bind() manufactures a new function
  4. the flag is part of the key too
  5. it fails without saying anything

basics

~20 s

removeEventListener matches on three things: the event type, the identical callback object, and the same capture flag. Two arrow functions with identical bodies are different objects, so nothing matches — and removeEventListener reports nothing when it removes nothing.

solid answer

~50 s

A registered listener is keyed by the tuple (type, callback, capture). `removeEventListener` walks that target's listener list looking for an entry equal on all three, and removes it if it finds one. Two separately written arrow functions with the same body are two distinct function objects, so the lookup misses and the original listener stays attached. The same trap hits `fn.bind(this)`, which returns a fresh function on every call, and any handler produced by a factory. The fix is to keep the reference: `const onClick = () => save()`, add it, remove that exact variable. Capture is part of the key, so a listener added with `{ capture: true }` needs `true` (or `{ capture: true }`) on removal too. `once`, `passive` and `signal` are ignored during matching. And `removeEventListener` never throws or warns when it matches nothing — the failure is completely silent.

code

javascript · 12 lines
javascript
const el = document.createElement('div');

// Fails: two distinct function objects
el.addEventListener('click', () => console.log('hi'));
el.removeEventListener('click', () => console.log('hi'));
el.click(); // still logs "hi"

// Works: same reference, and the capture flag agrees
const onClick = () => console.log('bye');
el.addEventListener('click', onClick, { capture: true });
el.removeEventListener('click', onClick, true);
el.click(); // logs "hi" only

go deeper

for a junior

Recall that you must keep the handler in a variable and pass that same variable to removeEventListener; an inline arrow function written twice creates two different functions and removal quietly does nothing.

for a middle

Explain the (type, callback, capture) key: why .bind() and factory-produced handlers break removal, why the capture flag must agree, and why re-adding the identical tuple is discarded rather than duplicated.

for a senior

Demonstrate how you diagnose it in a running app — the handler still firing after teardown, retained closures in a heap snapshot — and how you design attach/detach so the two sites cannot drift apart.

for a principal

Argue for eliminating the failure mode rather than reviewing for it: a lifecycle-scoped AbortController convention makes teardown structural, so anonymous handlers stop being a leak risk across a large codebase.

## The listener list and its key Every `EventTarget` keeps a list of registered listeners. Each entry records the event type, the callback, the capture flag, and the remaining option values. When you call `removeEventListener(type, callback, options)`, the browser looks for an entry whose **type**, **callback** and **capture** all match, and removes the first such entry. Nothing else is compared. That three-part key explains every surprising removal failure you will meet. ## Function identity, not function source ```js el.addEventListener('click', () => save()); el.removeEventListener('click', () => save()); // removes nothing ``` Each arrow-function expression evaluates to a brand-new function object. The two look identical in source, but they are different values, so the comparison fails and the listener remains. The browser has no way to compare "what the function does" — identity is the only thing it can check. The same applies to anything that manufactures a function at the call site: ```js el.addEventListener('click', this.onClick.bind(this)); el.removeEventListener('click', this.onClick.bind(this)); // a different bound function ``` `bind` returns a new exotic function object every time it is called. So does a factory such as `makeHandler(id)`. If you want to remove it, you have to hold on to the exact object you passed in: ```js this.boundClick = this.onClick.bind(this); el.addEventListener('click', this.boundClick); // later el.removeEventListener('click', this.boundClick); ``` ## Capture is part of the key A listener registered on the capture side is a *different registration* from one registered on the bubble side, even with the same type and callback — and you may legitimately have both. So removal must agree: ```js el.addEventListener('scroll', onScroll, { capture: true }); el.removeEventListener('scroll', onScroll); // no match el.removeEventListener('scroll', onScroll, true); // match el.removeEventListener('scroll', onScroll, { capture: true }); // also a match ``` The boolean third argument and `{ capture: … }` are interchangeable for this purpose; a missing third argument means `capture: false`. This is the second most common cause of a listener that "will not come off": the code that adds and the code that removes drifted apart on the flag. ## The other options are ignored on removal `once`, `passive` and `signal` take part in *registration* only. Passing `{ once: true }` or `{ passive: true }` to `removeEventListener` changes nothing about the match — the browser reads only `capture` from the options you hand it. You also do not need to pass the *same options object*; a fresh object with the same capture value matches fine. ## Duplicate registration is silently ignored The same key rule governs adding. If the (type, callback, capture) tuple is already in the list, `addEventListener` does nothing: ```js el.addEventListener('click', fn); el.addEventListener('click', fn); // discarded el.click(); // fn runs once ``` This is genuinely useful — an idempotent `attach()` is safe to call twice. But note the flip side: one `removeEventListener` call is then enough to detach it. It also means the duplicate-suppression only works for a *stable* callback reference; if `attach()` creates a new arrow function each time, every call really does add another listener, and you now have a leak you cannot clean up. ## The failure is silent `removeEventListener` returns `undefined` and never throws when nothing matches. There is no console warning, no return value to check. The only observable symptom is that the handler keeps firing — often long after the component that owned it was torn down, keeping its closure and any DOM nodes it captured alive. That is why this shows up in interviews as a memory-and-correctness probe rather than an API trivia question. ## Ways to stop worrying about identity Three patterns remove the problem instead of solving it repeatedly: - **Named references.** Declare the handler once, store it, remove that. The discipline is simple but easy to get wrong across a large component. - **`{ once: true }`.** For a genuinely one-shot listener, the browser removes it for you after the first invocation, so no reference is needed at all. - **`{ signal }`.** Register listeners with an `AbortSignal` and call `controller.abort()` at teardown. Removal no longer depends on holding each callback, which means anonymous inline handlers become safe to use. In code you own, the third pattern is usually the right default: one controller per lifecycle, one `abort()` in the teardown path, and the identity rule stops being something a reviewer has to check by eye.

  • Does the options object passed to removeEventListener have to be the same object that was passed to addEventListener?
    No. The browser reads only the `capture` value out of it, so a freshly written `{ capture: true }` matches a listener registered with `{ capture: true, passive: true }`. `once`, `passive` and `signal` play no part in the match — and passing them to `removeEventListener` has no effect at all.
  • What happens if you call addEventListener twice with the same type, callback and capture flag?
    The second call is a no-op — the listener is not added twice, and the callback runs once per event. That makes an `attach()` helper idempotent, but only when the callback is a stable reference; if it creates a new function each call, each one really is a separate registration.
  • How would you make a listener removable when the handler must be bound to a class instance?
    Either store the bound function once (`this.onClick = this.onClick.bind(this)` in the constructor, then add and remove that property), use a class field holding an arrow function, or skip binding entirely by passing the instance itself as the listener and giving the class a `handleEvent` method.
  • A component adds a window listener on mount and calls removeEventListener on unmount, but the handler keeps firing after navigation. Where do you look first?
    At what was actually passed to each call. Almost always the add site passes an inline arrow or a fresh `.bind()` result, or the two sites disagree about the capture flag. Log both references and compare them with `===`; if they differ, the removal was never going to match.

saying these in an interview costs you the question

  • Thinks two arrow functions with the same body are equal
  • Believes removeEventListener throws when nothing matches
  • Forgets the capture flag has to match on removal
  • Claims once or passive affect removal matching
  • Assumes adding the same function twice makes it fire twice

context