Visitar URL original
JavaScript Event Cleanup and Page Lifecycle

JavaScript Event Cleanup and Page Lifecycle

Published Updated

Remove JavaScript event listeners when the feature that needs them closes. Use removeEventListener with the original function, or register listeners with an AbortSignal and abort them together. This prevents repeated handlers and unwanted references to old data.

This is part of our JavaScript and the DOM guides. Start with the JavaScript overview if functions and objects are new to you.

The examples that print output also run in Node, 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 Can Event Listeners Keep Old Data in Memory?

An event target keeps a reference to each registered listener. If the listener uses an old object, that reference can keep the object in memory. Remove the listener when it is no longer needed, especially on long-lived targets such as window.

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

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

Why Does removeEventListener Need the Same Function?

removeEventListener matches the event type, function object, and capture setting used during registration. A new function with identical code is still a different object, so it does not remove the original listener.

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

How Do You Remove Several Listeners with AbortController?

Pass the same AbortController signal to several addEventListener calls, then call abort to remove those listeners together. This only removes listeners registered with that signal.

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 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 output:
// listener added after abort ran: 0

That is a 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.

How Do You Run an Event Listener Only Once?

Use { once: true } when an event listener should run once. The browser removes it before calling the function, so even a recursive dispatch of the same event cannot call it 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 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 output:
// times the handler ran: 1

Reach for it on things that 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 Should JavaScript Run to Access the DOM?

Run DOM-dependent JavaScript after the elements it needs have been parsed. An external classic script with defer, or a module script without async, runs after parsing. A plain classic script runs where the parser encounters it.

A selector can return null when its element has not been parsed yet or when the query uses the wrong root, such as 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" and no async 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();
}

Does React Remove Manually Added Event Listeners?

React does not automatically remove listeners you manually add to window or document. Return cleanup from the effect that adds them. React handles its own event props, and unreachable elements can be garbage-collected with their listeners.

The case that 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 an external deferred script or a module script without async avoids. 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

Are JavaScript event listeners removed automatically?

JavaScript event listeners can be garbage-collected with an element when nothing still references it. Listeners on long-lived targets such as window or document remain registered until removed, aborted, or removed by the once option. Clean them up when the feature no longer needs them.

Can AbortController remove JavaScript event listeners?

AbortController can remove JavaScript event listeners registered with its signal. Pass the signal to addEventListener, then call abort on the controller. The same signal can also cancel fetch requests that use it, but it does not clear timers.

Should an external JavaScript file use defer or sit at the end of body?

An external classic JavaScript file can use defer or sit at the end of body to access earlier page elements. Defer makes the loading behavior explicit and runs deferred scripts in source order after parsing. It does not affect inline classic scripts.

How can you find a JavaScript event-listener memory leak?

Look for JavaScript event listeners that remain after their feature closes, especially on window or document. Repeated callbacks can reveal duplicate registration. Browser developer tools can inspect listeners and compare heap snapshots to find objects retained after cleanup.

Sources

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