Web Platform · DOM and Events
Listener Identity, Options, and Removal During Dispatch
Registrations are keyed by type, callback, and capture flag alone, so options like once and passive stick to the first registration, and removal criteria are narrower than most callers expect.
addEventListener looks like a set insertion, and in a narrow sense it is one. The event listener list on a target holds entries, and an entry is identified by three values: the event type string, the callback, and the capture flag. A registration whose three values match an existing entry is discarded without touching the existing entry, including its other options. That single rule generates a family of behaviours that reading the signature alone does not predict.
The identity triple
The comparison is on the callback object, not on its source text. Two arrow functions with identical bodies are distinct callbacks and produce two entries; the same function reference registered twice produces one. The type string is compared exactly, case-sensitively, which is why 'Click' never matches the browser’s click.
The capture flag participates in identity. The same function can be registered twice on one node, once for capture and once for bubble, and both entries fire: the capture entry during the capture pass, the bubble entry during the bubble pass. On the target node both passes occur, so both fire there too, in pass order rather than registration order.
Because identity ignores the remaining options, the first registration wins them all:
const onClick = () => console.log('clicked');
button.addEventListener('click', onClick, { once: true });
button.addEventListener('click', onClick); // ignored, not merged
button.addEventListener('click', onClick, { capture: false, passive: true }); // ignored
The second and third calls find a matching entry and return. The listener stays once and stays non-passive. Any design that allows two parts of a program to configure the same handler independently has to decide this conflict itself, because the platform resolves it silently in favour of whoever ran first.
Options and what each one changes
Four members are defined on the options dictionary.
capture selects the pass. When the third argument is a boolean, it is interpreted as capture; the dictionary form exists to carry the rest.
once marks the entry for removal before the callback is invoked. The removal happens first, so a removeEventListener call inside the callback is redundant but harmless, and a second event arriving during the callback cannot re-enter through the same entry. If the same callback is registered for the same type and capture flag on the same target a second time, once from the first registration still governs, per the identity rule.
passive is a promise that the callback will not call preventDefault. The browser is permitted to act on the assumption immediately: for events that would otherwise block scrolling, such as touchstart, touchmove, wheel, it can start the scroll without waiting for the handler to finish. A preventDefault call inside a passive listener is ignored: by the time the callback runs, the browser may already have started the scroll, so the cancellation has nothing left to prevent.
signal ties the registration’s lifetime to an AbortSignal. When the signal aborts, the entry is removed, with the same effect as an explicit removal call. This is the only removal mechanism that does not require keeping the exact callback reference and capture flag at the removal site.
const controller = new AbortController();
const { signal } = controller;
window.addEventListener('resize', () => relayout(), { passive: true, signal });
window.addEventListener('scroll', () => track(), { passive: true, signal });
// later, one call removes every listener registered with this signal
controller.abort();
Function callbacks, objects, and this
A listener can be a function or an object with a handleEvent method. For a function, this inside the callback is the currentTarget, unless the function is an arrow function, in which case the lexical this wins and the binding is lost. For an object, this is the object itself, which is the reason to use the form: it keeps handler state on a stable object without binding.
The object form has a subtle identity advantage. A single object can implement handleEvent for many event types and be registered once per type, and it can be removed with removeEventListener(type, theObject) from anywhere that holds the object. It has no advantage in lifetime: the target holds a strong reference to it.
Removal is stricter than addition
removeEventListener takes the type, callback, and either a boolean or an options dictionary from which only capture is read. All other options are ignored. Passing { once: true } at removal time does not help; passing { capture: true } when the entry was registered for bubble does nothing; passing a fresh arrow function does nothing.
The consequence is that anonymous callbacks are effectively permanent for the life of the target. This is the standard mechanism behind listener leaks in long-lived applications: a component registers an arrow function on window, the component is discarded, the entry remains, and the closure it holds keeps the component’s retained graph alive. The signal option exists precisely because it converts that pattern into something a single abort can clean up. For the same reason, capture entries need their flag remembered; a common bug is registering with true and removing with no third argument.
Removal during dispatch follows the spec’s clone-then-check procedure. When a node is invoked, its listener list is cloned, and each entry is checked for a removed flag before being called. Removing a not-yet-invoked listener from an earlier callback in the same dispatch prevents it from running. Adding a listener during dispatch does not cause it to run on a node whose invocation has already started, but a node that dispatch has not reached yet will see it: the clone is taken when that node is invoked, not when dispatch begins. Neither operation changes the path itself, since that was fixed earlier; event propagation covers that snapshot.
Delegation trades path length for entry count
Attaching one listener per row of a long table means one entry per row, each holding a closure, each contributing to the cost of dispatch on that target. Attaching one listener to the table and resolving the row from event.target means one entry and no per-row memory, at the price of a lookup per event.
The lookup is usually closest, which walks ancestors from the target until the selector matches. That walk is proportional to depth, not to the number of rows, and it happens once per event rather than once per registered listener. The delegation pattern is therefore cheaper in memory in almost every case, and cheaper in dispatch time whenever the number of would-be listeners exceeds the depth of the tree.
table.addEventListener('click', (event) => {
const row = event.target.closest('tr[data-id]');
if (!row || !table.contains(row)) return; // ignore clicks from nested tables
select(row.dataset.id);
});
The contains check, built on the ancestor walk described in node identity and structural mutation, matters when delegated containers nest: closest can climb past the container’s own boundary and match a row belonging to an outer table. Filtering after the walk costs another ancestor traversal but keeps the handler honest. The alternative, comparing against the container as the walk proceeds, is the same amount of work with more code.
Passive defaults are an engine policy, not a standard rule
The specification defines what passive: true means, not which listeners are passive by default. Chromium treats listeners for touchstart, touchmove, wheel, and mousewheel registered on window, document, and document.body as passive unless they are registered with passive: false. The same listener on an arbitrary element is not passive by default, and the exact set of events and root targets differs between engines, so a listener that must cancel has to say so explicitly. The policy is visible only through its effect: preventDefault in a root-level wheel handler stops working silently, while the identical handler attached to a scrolling container still cancels the scroll.
This asymmetry is the reason libraries that implement custom scrolling attach their handlers to the scrolling element rather than to the document, and pass passive: false explicitly. It also means a polyfill that moves a handler from an element to the document can change its behaviour without changing its code.
Targets other than elements
EventTarget is not tied to the DOM tree. AbortSignal is itself an event target, as are XMLHttpRequest, MessagePort, AudioContext, and a class extending EventTarget directly. A custom target has no path: dispatch invokes listeners on the object itself and nothing else, since it has no parent chain. Any object can therefore be a small event bus with the platform’s semantics, including capture entries, which simply behave as ordinary entries because there is no ancestor to capture toward.
The cost model differs from DOM nodes in one respect worth knowing: node dispatch has to build the path, which is proportional to depth, while a bare EventTarget dispatch has no path at all. For high-frequency internal signalling, the bare target is the cheaper carrier; for anything that must travel through the document, the path construction is unavoidable and scales with nesting depth.
References
- DOM Standard: EventTarget and EventListener — WHATWG
- UI Events — W3C
- JavaScript: The Definitive Guide — David Flanagan, O'Reilly Media