Listener Cleanup and Page Lifecycle

Published Updated

By the end of this page you will know how to take a listener back off, how to retire a whole feature's listeners with one call, when your script runs against the page being built, and why none of this goes away when you move to a framework.

This is the least glamorous page in the DOM topic and the one that separates a demo from something people use for an hour. The examples that print output ran under Node 24.16.0, because the listener machinery here is EventTarget, the same interface every DOM element inherits from. The parts that need a real page say so.

Why a Listener Outlives What Added It

Adding a listener creates a lasting arrangement. The target keeps a reference to your function until something removes it, and the ways that happens are an explicit removal, the once option, an aborted signal, or the target itself going away.

That is fine when both live and die together. It goes wrong when the listener sits on something permanent while the thing that cared about it is temporary:

function openPreview(item) {
  window.addEventListener("resize", () => {
    document.querySelector("#preview").textContent = item.title;
  });
}

Open the preview five times and there are five resize listeners, all live, all firing on every resize. The fifth one is doing the visible work and the other four are doing it again underneath.

The cost is not only wasted work. Each of those functions is a closure over item, the live binding behaviour covered in Functions, Callbacks, and Closures, so each one retains that data for as long as window exists. Whether a person notices depends on how much is retained and how often the event fires.

One detail slightly softens the picture and is worth knowing exactly. Adding the identical function twice for the same target, event type, and capture setting registers it once, not twice:

const target = new EventTarget();
let count = 0;
const handler = () => { count += 1; };

target.addEventListener("ping", handler);
target.addEventListener("ping", handler);
target.dispatchEvent(new Event("ping"));

console.log("times the handler ran:", count);
// node 24.16.0 output:
// times the handler ran: 1

That protection is narrower than it looks. It needs the very same function object, and an arrow function written inline creates a brand new one every time the surrounding code runs, so the duplicates in the first example are all distinct. The same function registered once for capture and once for bubble is also two listeners, not one.

removeEventListener Needs the Same Function

The removal method takes the event name and the function, and it removes nothing unless the type, the function, and the capture setting all match what you added. The function is the part people get wrong, and it is the most common reason cleanup code appears to do nothing:

const target = new EventTarget();
let count = 0;

target.addEventListener("pong", () => { count += 1; });
target.removeEventListener("pong", () => { count += 1; });
target.dispatchEvent(new Event("pong"));

console.log("times the handler ran after removal:", count);
// node 24.16.0 output:
// times the handler ran after removal: 1

Two identical-looking arrow functions are two different objects, so the removal matched nothing and the listener stayed. No error was raised, which is what makes it so easy to miss.

The fix is to give the function a name and keep hold of it. Browser-side, that looks like this:

function handleResize() {
  console.log("resized");
}

window.addEventListener("resize", handleResize);
window.removeEventListener("resize", handleResize);

The same rule applies to a bound function, since .bind() returns a new function every time it is called. Store the bound version in a variable and pass that variable to both calls. If you registered with capture: true, pass it again when removing; once, passive, and signal play no part in the match.

One AbortController for Every Listener

Keeping a named reference for each of ten listeners is tedious and easy to get half right. Pass one controller's signal to all of them instead, and a single call retires the lot:

const target = new EventTarget();
const controller = new AbortController();
let total = 0;

target.addEventListener("a", () => { total += 1; }, { signal: controller.signal });
target.addEventListener("b", () => { total += 10; }, { signal: controller.signal });

target.dispatchEvent(new Event("a"));
target.dispatchEvent(new Event("b"));

controller.abort();

target.dispatchEvent(new Event("a"));
target.dispatchEvent(new Event("b"));

console.log("total after aborting:", total);
// node 24.16.0 output:
// total after aborting: 11

Both listeners fired once, the abort removed both, and the second pair of dispatches changed nothing. No named function had to be kept for either one.

The controller is safe to use after aborting, in the sense that it will not throw. A listener added with a signal that has already been aborted is simply never registered:

let late = 0;
target.addEventListener("c", () => { late += 1; }, { signal: controller.signal });
target.dispatchEvent(new Event("c"));

console.log("listener added after abort ran:", late);
// node 24.16.0 output:
// listener added after abort ran: 0

That is a real trap in a feature that reopens. A controller is spent once aborted, so create a fresh one each time the feature starts rather than reusing the old one.

This is the same AbortController that cancels a request in the fetch guide. One controller can therefore own a feature's listeners and its in-flight requests together, and one abort call closes the whole thing down.

The Listener That Should Only Fire Once

When a listener's whole job is to run one time, the once option takes it off for you. The removal happens before your function is called, which matters if that function dispatches the same event again:

const target = new EventTarget();
let count = 0;

target.addEventListener("solo", () => { count += 1; }, { once: true });
target.dispatchEvent(new Event("solo"));
target.dispatchEvent(new Event("solo"));

console.log("times the handler ran:", count);
// node 24.16.0 output:
// times the handler ran: 1

The before, not after, matters when the handler can cause the same event again. Here it dispatches recursively and still runs exactly once, because it was already off the target by the time it started:

const target = new EventTarget();
let calls = 0;

target.addEventListener("boom", function handler() {
  calls += 1;
  if (calls < 5) target.dispatchEvent(new Event("boom"));
}, { once: true });

target.dispatchEvent(new Event("boom"));

console.log("times the handler ran:", calls);
// node 24.16.0 output:
// times the handler ran: 1

Reach for it on things that genuinely happen once, such as dismissing a first-run banner or reacting to the first interaction that unlocks audio playback. A dialog closing is not one of them, since a dialog can reopen and close again. It is cleanup you cannot forget to write.

When Your Script Actually Runs

Timing is the first thing to rule out when a selector you believe in returns null. A classic script tag with no defer runs the moment the parser reaches it, and elements further down the page do not exist yet.

It is not the only cause. A correctly spelled selector can also match nothing yet, or be run against the wrong root: another document, an iframe, or a shadow tree.

The defer attribute is the fix worth defaulting to. The browser downloads the file while it carries on parsing, then runs deferred scripts in source order once parsing is finished:

<head>
  <script defer src="app.js"></script>
</head>

A script with type="module" behaves this way already and needs no defer attribute. That holds for an inline module too: it has no file to fetch, but it is still evaluated as a module and still runs after parsing rather than where it sits.

An inline classic script is the one that always runs where it sits. defer has no effect on it, because the attribute only governs a script the browser has to fetch.

Its opposite, async, runs the file as soon as it has downloaded, interrupting parsing, in whatever order the downloads finish. That suits an independent script such as analytics and does not suit code that touches your elements.

Two page events complete the picture. DOMContentLoaded fires once the HTML has been parsed and the deferred and module scripts have run, and it does not wait for images. It does not wait on stylesheets directly either, though deferred scripts do, and it waits for them:

document.addEventListener("DOMContentLoaded", () => {
  document.querySelector("#app").textContent = "Ready";
});

The load event on window waits for the page's dependent resources, styles, scripts, frames, and images that are not lazily loaded, so it fires noticeably later. Use it only when you need a resource to have finished, such as measuring an image.

With defer in place you rarely need DOMContentLoaded at all, since your code already runs after parsing. It stays useful for a script you do not control the loading of, and there the event alone is not enough: an async or dynamically inserted script can start after the event has already fired, and a listener added then never runs.

Check the state before deciding, so the code works either way:

function start() {
  document.querySelector("#app").textContent = "Ready";
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
  start();
}

Why These Leaks Survive a Framework

Moving to React or Vue does not retire this page. Event systems differ between frameworks, and a hand-added listener on a node the framework removes can be collected with that node, so the danger is narrower than the folklore.

The case that actually bites is a listener on a long-lived target such as window or document, added from inside a component. Nothing the framework unmounts is that target, so only your own teardown removes it:

useEffect(() => {
  const controller = new AbortController();

  window.addEventListener("resize", handleResize, { signal: controller.signal });
  document.addEventListener("keydown", handleEscape, { signal: controller.signal });

  return () => controller.abort();
}, []);

That returned function is React's teardown, and it runs on unmount and before the effect runs again. The controller pattern means both listeners come off in one line, and a forgotten one is much harder to arrange.

React's Strict Mode makes this visible in development by running each effect through an extra setup, cleanup, setup cycle. Missing cleanup shows up there, though not always as a doubled call, since re-registering the identical function for the same type and capture is deduplicated. Read it as React pointing at your teardown rather than as a bug in React.

The same reasoning applies outside React. Code that runs again, whether on a route change, a reopened panel, or a re-rendered list, needs to undo whatever it set up that accumulates or persists. Work that simply overwrites a value each time needs no teardown.

Pitfalls and Debugging

The handler runs twice, then three times. The likely cause is setup code running repeatedly, each run adding a new function. Confirm it before fixing it, since two real dispatches produce the same count, then move to a controller you abort on the way out, or keep a named function and remove it.

removeEventListener changed nothing. The function passed to it was not the function that was added, usually because both were written inline. Store it in a variable and pass that variable both times.

The selector is right and the element is null. Most often a classic script ran before the element was parsed, which defer fixes and which a module script does not have. Check the search root too, since a query against the wrong document, an iframe, or a shadow tree gives the same answer.

The feature works once and never again. An AbortController is being reused after it was aborted, so the second round of listeners is never registered. Create a new controller each time the feature starts.

The page gets slower the longer it is open. Look for listeners on window, document, or body added by code that runs more than once. Those targets outlive almost every feature attached to them, even though a navigation does eventually retire the document.

An interval is still running after the panel closed. Timers are not listeners and a controller does not touch them. Call clearInterval in the same teardown, as the timers guide covers.

Frequently Asked Questions

Do listeners not get cleaned up automatically?

Sometimes. When an element is removed and nothing else refers to it, the element and its listeners can be collected together. The leaks happen when the listener is on something long-lived such as window or document, because that target keeps the function alive, and the function retains the values it actually refers to.

Is AbortController only for cancelling fetch?

No. The same controller cancels listeners too: pass its signal in the options object of addEventListener, and one abort call removes every listener registered with it. It is the same object either way, so a single controller can retire a feature's requests and its listeners together.

Is defer better than putting the script at the end of the body?

Both work, and defer is the one to reach for. It states the intent in the tag: download while parsing continues, then run in source order once parsing finishes. A body-end script relies on position for the same effect, and browsers may still fetch it early through speculative scanning.

How do you tell whether you have a leak?

Watch for the symptom first: a handler that fires more often each time you reopen a feature. In Chrome DevTools, the Elements panel has an Event Listeners tab for the selected element, and the Memory panel compares heap snapshots taken before and after using the feature. Other browsers offer equivalent views under their own names.

Sources

  1. [1]
  2. [2]
    Document: DOMContentLoaded Event
    (developer.mozilla.org)
  3. [3]
    The Script Element
    (developer.mozilla.org)