Delegation in JavaScript: Events, Prototypes, Pitfalls

Aug 2, 2026·18 min read

A task list has two hundred rows and every row carries a delete button. You can attach two hundred click listeners, or you can attach one.

Attaching one is delegation. It is also why a row inserted ten minutes after page load deletes itself with no re-binding: nothing was ever bound to it.

The word names two other things in JavaScript as well, which is why a search for it returns a mix of DOM tutorials and prototype-chain explainers. This article covers the DOM sense properly, including the four ways it fails without an error, and then the other two.

What “delegation” means in JavaScript

Delegation is one object doing work on behalf of another instead of every object handling its own. JavaScript uses the word in three distinct senses:

  1. Event delegation. One DOM listener on an ancestor handles events fired on its descendants.
  2. Prototype delegation. A property lookup an object cannot satisfy is passed up its [[Prototype]] chain until something answers it.
  3. API delegation. An object forwards method calls to another through call, apply, bind, or a Proxy that hands the operation to Reflect.

Sense 1 is what people mean when they use the word unqualified, and it takes up most of what follows. Sense 2 gets its own section near the end, and sense 3 a few paragraphs inside it. The three share a shape but not a mechanism. Bubbling has nothing to do with prototypes, and a Proxy forwarding a call is not walking a chain.

How event delegation works: bubbling, target, and currentTarget

The DOM standard gives an event four phase values: NONE (0), CAPTURING_PHASE (1) before the event reaches its target, AT_TARGET (2) on its target, and BUBBLING_PHASE (3) after it reaches its target. A click on a <button> inside an <li> inside a <ul> travels down from the root to the button, fires there, then travels back up through the <li> and the <ul>. event.eventPhase holds the current number while a handler runs.

Delegation lives in phase 3. A listener on the <ul> runs for a click that started three elements deeper, because the event passes through it on the way back up. Introduction to browser events covers the full propagation model.

Two properties tell you where you are. MDN defines event.target as “a reference to the object onto which the event was dispatched”, and it “is different from Event.currentTarget when the event handler is called during the bubbling or capturing phase of the event.” event.currentTarget is the element whose listener is currently running, which is the element you called addEventListener on.

<ul>your listenercurrentTarget<li><button>targetcapturephase 1bubblephase 3click lands hereAT_TARGET, phase 2
One click, two names: target is where it landed, currentTarget is where your listener sits.
const list = document.querySelector('#tasks');

list.addEventListener('click', (event) => {
  // currentTarget: the <ul>, on every click, always
  // target: the deepest node under the pointer, which may be
  // the <li>, a <span> inside it, or an <svg> icon
  const item = event.target.closest('li');
  if (item) item.classList.toggle('done');
});

One listener covers every row, including rows that do not exist yet. The trade is on the other side: that handler runs for every click anywhere inside the list, matched or not. The guard clause doing the matching is load-bearing, and writing it correctly is the whole game.

Match the right element: closest(), not target.tagName

Almost every tutorial matches like this:

// broken
list.addEventListener('click', (event) => {
  if (event.target.tagName === 'LI') {
    remove(event.target);
  }
});

That holds exactly as long as rows contain nothing but text. Wrap the label in a <span>, add an icon, bold the title, and event.target is the span, the icon or the <strong>:

<ul id="tasks">
  <li class="item"><span class="label">Buy milk</span> <button>×</button></li>
</ul>

Click the words and event.target is the <span>; click the delete control and it is the <button>; only a click on the padding around them lands on the <li> itself. So tagName is almost never 'LI', the branch almost never runs, nothing throws, and the click is ignored. That regression ships.

Element.closest() is the fix. MDN says it “traverses the element and its parents (heading toward the document root) until it finds a node that matches the specified CSS selector”, returning that ancestor or the element itself, and null when there is no match. That is the exact question you are asking: which row did this click land in?

list.addEventListener('click', (event) => {
  if (!(event.target instanceof Element)) return;

  const item = event.target.closest('.item');
  if (!item || !list.contains(item)) return;

  remove(item);
});

Three lines, three jobs. The instanceof Element check exists because event.target is not always an element: it can be document or window, and neither has a closest method. The closest('.item') call climbs from whatever was hit. The contains() guard keeps the answer inside this container, because closest() will keep walking past list and match a .item further up the page if one is there. A node contains itself, so list.contains(list) is true and a listener sitting on a matching container still works.

.itemelsewhere on the pagecontains(): false#tasksyour listenercontains(): true.itemthe row you wantclosest() stops here<span>event.targetclosest()climbs upno match?it keeps going
closest() climbs until something matches; contains() decides whether the match is yours.

For one listener serving every row’s buttons, give each button a data-action attribute and look the handler up in a table instead of writing an if/else ladder:

const rows = document.querySelector('#rows');

const actions = Object.create(null);
actions.delete = (row) => row.remove();
actions.duplicate = (row) => row.after(row.cloneNode(true));
actions.pin = (row) => row.classList.toggle('pinned');

rows.addEventListener('click', (event) => {
  if (!(event.target instanceof Element)) return;

  const button = event.target.closest('[data-action]');
  if (!button || !rows.contains(button)) return;

  const run = actions[button.dataset.action];
  const row = button.closest('.row');
  if (run && row) run(row);
});

The Object.create(null) is where senses 1 and 2 of delegation collide. A plain object literal delegates failed lookups to Object.prototype, so a button carrying data-action="toString" finds a function:

const table = { delete: () => 'deleted' };

console.log(typeof table['delete']);
console.log(typeof table['toString']);

const safe = Object.assign(Object.create(null), table);
console.log(typeof safe['toString']);
function
function
undefined

An object created with Object.create(null) has no prototype to delegate to, so an unknown action is undefined and the handler does nothing. A Map gets you the same protection.

Which events you can delegate, and what to use instead

An event is delegatable in the bubbling phase if it bubbles. That is the whole test for the usual route; the capturing phase, a few paragraphs down, is the way around it for the events that do not. Check the Bubbles row on the event’s MDN page, or log event.bubbles from a handler on the element itself.

Four common events fail it, and each has a bubbling substitute. MDN’s reference page for each event describes the pairing:

  • focusfocusin. The two “differ in that focusin bubbles, while focus does not.”
  • blurfocusout. “The event does not bubble, but the related focusout event that follows does bubble.”
  • mouseentermouseover. mouseenter “doesn’t bubble and it isn’t sent to any descendants when the pointer is moved from one of its descendants’ physical space to its own physical space.”
  • mouseleavemouseout. “mouseleave does not bubble and mouseout does.”

The substitutes match on reach, not on behaviour. mouseover and mouseout fire again every time the pointer crosses a boundary between descendants inside the same row, so a delegated hover handler runs far more often than mouseenter would. event.relatedTarget holds the element the pointer came from on mouseover, and the one it is heading to on mouseout, which is enough to tell a genuine entry from an internal move:

if (row.contains(event.relatedTarget)) return; // the pointer only moved within the row

The focus pair has a second route. MDN states it flatly: “There are two ways of implementing event delegation for this event: by using the focusin event, or by setting the useCapture parameter of addEventListener() to true.” The capturing phase runs on the way down and never consults the bubbles flag, which is why a capture listener on an ancestor sees an event a bubble listener never will. That boolean third argument is the legacy spelling; { capture: true } in the options object is the current one.

Where delegation breaks

Four failures. Three produce no error at all; the fourth surfaces as a TypeError on null far from its cause.

A descendant called stopPropagation(). Per MDN, it “prevents further propagation of the current event in the capturing and bubbling phases”, so a handler on a button inside your row switches off the listener on the container without knowing it exists. It “does not prevent propagation to other event-handlers of the current element”; stopImmediatePropagation() is the one that also silences the sibling listeners on the same node. Neither prevents the default browser action, which Browser default actions covers separately. When a delegated handler stops firing after somebody else’s commit, look for this first: a capture-phase listener on your container still sees the event, because it runs before the descendant’s handler does.

A disabled control swallowed the click. A click that lands on a <button disabled> itself is never dispatched on the button, so nothing reaches the delegate. If you need to know that someone pressed an unavailable button, drop the attribute: render the button enabled, mark it aria-disabled="true", and return early in the handler.

Shadow DOM retargeted the event. Seen from outside a shadow root, event.target is the host element, not the node inside it that was clicked. A click the browser dispatches is composed and does cross the boundary; a CustomEvent is not unless you pass composed: true, and an uncomposed event stops at the shadow root, where a delegate outside the host never sees it at all. event.composedPath() “returns the event’s path which is an array of the objects on which listeners will be invoked”, so composedPath()[0] is the real innermost target and the thing to run closest() against. The usual guard does not cross with it, though: closest() climbs element parents and stops at the shadow root, which is a DocumentFragment and not an element, while contains() sees only the node tree, so container.contains(node) is false for anything inside a shadow tree. Either test the path itself, where event.composedPath().includes(host) tells you the click came from that component, or attach the delegate inside the shadow root, where event.target is already the node you want. One limit: the path “does not include nodes in shadow trees if the shadow root was created with its ShadowRoot.mode closed.” Shadow DOM and events goes through retargeting in full.

#appdelegate listener<my-widget>#shadow-root<button>clickedfrom a listener outside the host:event.target = <my-widget>composedPath()[0] = <button>app.contains(button) = false
From outside the boundary the click looks like it hit the host; composedPath keeps the real target.

currentTarget went null. MDN: “The value of currentTarget is only available in a handler for the event. Outside an event handler it will be null.” An async handler that reads event.currentTarget after an await reads null. Copy it into a local variable on the first line.

Make the clickable thing a real <button> or <a> as well. It gets keyboard activation and focus behaviour for free, and your delegate matches on closest('button[data-action]') instead of guessing which <div> counts as a control.

Removing and debugging delegated listeners

removeEventListener needs the identical function reference you passed in, which means keeping it alive somewhere for the lifetime of the component. An AbortSignal deletes that bookkeeping. The options object accepts a signal, and per MDN, “the listener will be removed when the abort() method of the AbortController which owns the AbortSignal is called”:

const controller = new AbortController();
const { signal } = controller;

list.addEventListener('click', onListClick, { signal });
list.addEventListener('focusin', onListFocus, { signal });
list.addEventListener('keydown', onListKeys, { signal });

controller.abort(); // all three, gone

One abort() clears every listener registered with that signal, whatever the type and whatever the node. The same options object takes once: true for a listener that removes itself after it fires.

When a delegated handler does not run, the question is which ancestor owns a listener. Chrome DevTools’ Console Utilities API answers it: getEventListeners(object) “returns the event listeners registered on the specified object” as an object holding an array per event type, and monitorEvents(object, 'click') logs the Event object to the console each time that event occurs. Both work only in the DevTools console, never in page scripts.

Frameworks run the same pattern at application scale. Since React 17, “React will call rootNode.addEventListener() under the hood”, attaching to the root DOM container the tree renders into; before that, “React would do document.addEventListener() for most events”. jQuery’s .on(type, selector, handler) takes an optional selector that filters descendants, and with it “the handler is not called when the event occurs directly on the bound element, but only for descendants (inner elements) that match the selector.”

The other delegation: prototypes and behavior delegation

Sense 2 works on property lookups. MDN describes the mechanism: “Each object has an internal link to another object called its prototype… When trying to access a property of an object, the property will not only be sought on the object, but also on the prototype of the object, the prototype of the prototype, and so on, until either a property with a matching name is found or the end of the prototype chain is reached.”

The shape matches the DOM version exactly. A lookup the object cannot answer travels up a chain until something answers it, the way a click a row does not handle travels up until a container handles it. Object.create() links one object to another directly:

const Task = {
  setID(id) { this.id = id; },
  outputID() { return `task ${this.id}`; }
};

const XYZ = Object.create(Task);
XYZ.prepareTask = function (id) {
  this.setID(id);
  return this.outputID();
};

console.log(XYZ.prepareTask(7));
console.log(Object.hasOwn(XYZ, 'setID'));
console.log(Object.getPrototypeOf(XYZ) === Task);
task 7
false
true

XYZ owns no setID. The call finds it on Task and runs it with this still pointing at XYZ, which is why id lands on XYZ. Kyle Simpson named this style in Chapter 6 of “this & Object Prototypes”, titled “Behavior Delegation”: “let some object (XYZ) provide a delegation (to Task) for property or method references if not found on the object (XYZ).” He calls it OLOO, “objects-linked-to-other-objects”, a style that creates and relates objects directly without the abstraction of classes. Prototypal inheritance works through the chain in detail.

Link the chain when you create the object, not afterwards. MDN warns that changing an object’s [[Prototype]] “is, by the nature of how modern JavaScript engines optimize property accesses, currently a very slow operation in every browser and JavaScript engine”, and recommends creating a new object with the prototype you want through Object.create() rather than reaching for Object.setPrototypeOf().

Sense 3, API delegation, is forwarding with no chain involved. call and apply run a function with this aimed at another object, and bind returns a copy permanently attached to one, which Function binding covers. A Proxy intercepts an operation and hands it to Reflect, which performs the default behaviour against whatever target you name:

const engine = { start: () => 'engine running' };

const car = new Proxy({ wheels: 4 }, {
  get(target, prop, receiver) {
    return prop in target
      ? Reflect.get(target, prop, receiver)
      : Reflect.get(engine, prop, engine);
  }
});

console.log(car.wheels);
console.log(car.start());
4
engine running

No prototype link exists between car and engine. The forwarding is written by hand in the trap, and that is the whole difference between sense 3 and sense 2. The Proxy Pattern (vs the Proxy Object) separates the two further.

One name to set aside while you search: attachShadow() accepts a delegatesFocus option, which MDN describes as “a boolean that, when set to true, specifies behavior that mitigates custom element issues around focusability. When a non-focusable part of the shadow DOM is clicked, the first focusable part is given focus, and the shadow host is given any available :focus styling.” It defaults to false. That is a focus feature of the DOM, and none of the three senses above.

A delegation checklist

  1. Delegate on the nearest stable container that outlives its children, not on document.
  2. Match with event.target.closest(selector) and guard with container.contains(match). Never with target.tagName.
  3. Confirm the event bubbles before delegating it. focusin, focusout, mouseover and mouseout stand in for the four that do not.
  4. Expect stopPropagation() somewhere below you. A capture-phase listener on your container still runs.
  5. When the click lands inside a shadow tree and your listener sits outside it, event.target is the host. Match against event.composedPath()[0], and check the path rather than contains(), which does not see into shadow trees.
  6. Read event.currentTarget into a local variable before any await.
  7. Register with a signal from an AbortController so teardown is one abort() call.

Frequently asked questions

Why does event.target.tagName === 'LI' stop working?
Because event.target is the innermost element the pointer actually hit. The moment a row contains a <span>, an icon or a <strong>, the target is that inner node and the tag check never matches. Use event.target.closest('.item') instead, then confirm the container still contains the result.
Which events cannot be delegated?
Any event that does not bubble. The four you meet most often are focus, blur, mouseenter and mouseleave. Their bubbling substitutes are focusin, focusout, mouseover and mouseout. For focus, listening in the capturing phase on an ancestor is the other option.
Why does my delegated click handler never fire for a disabled button?
Disabled form controls do not receive mouse clicks or focus-related events at all, so nothing is dispatched and nothing bubbles to the delegate. If you need the click, render the button enabled, mark it aria-disabled="true" and return early in the handler.
How do you remove a delegated listener?
Pass a signal from an AbortController in the options object when you register it. Calling abort() removes every listener registered with that signal at once, so you never have to keep the original function reference around for removeEventListener.