Delegation and Batching

Event Delegation, DocumentFragment and Batching

Event delegation puts one listener on a container instead of every child. A click on an <li> bubbles to the <ul> (The Event Object), where event.target.closest('li') finds the row. Rows added later need no listeners of their own.

A DocumentFragment is a parentless node outside the document, so building a subtree in it never dirties layout; list.append(fragment) moves all its children in at once. The listing, run in headless Chrome 152 1 , builds 2,000 items three ways.

Live inserts, forced layouts and a fragment, then one delegated listenerJavaScript
function build(list, useFragment, scrollEach) {
  const target = useFragment ? document.createDocumentFragment() : list;
  for (let i = 0; i < 2000; i++) {
    const li = target.appendChild(document.createElement('li'));
    li.textContent = `Item ${i}`, li.dataset.id = i;
    if (scrollEach) list.scrollTop = list.scrollHeight;  // a read: forces layout if dirty
  }
  if (useFragment) list.append(target);  // one insertion, one layout
}
let list;
for (const [label, useFragment, scrollEach] of [['live list', false, false],
  ['live list + scroll', false, true], ['fragment + scroll', true, true]]) {
  list?.remove();
  list = document.body.appendChild(document.createElement('ul'));
  list.style.cssText = 'height: 200px; overflow: auto';
  const t0 = performance.now();
  build(list, useFragment, scrollEach);
  list.scrollTop = list.scrollHeight;
  console.log(`${label}: ${(performance.now() - t0).toFixed(1)} ms`);
}
list.addEventListener('click', (e) => {  // one listener for every current and future <li>
  console.log(`clicked item ${e.target.closest('li')?.dataset.id}`);
});
list.children[42].click();
Output
live list: 37.5 ms
live list + scroll: 1234.9 ms
fragment + scroll: 30.2 ms
clicked item 42

The fragment barely beats live inserts, because layout already waits until something reads it (Reflow, Repaint and Batching). It wins 40-fold when a read such as "keep the newest row in view" sits in the loop. Chrome's metrics also showed 2,000 listeners and 65 KB more heap for per-item handlers.

focus, blur and mouseenter do not bubble: delegate focusin, focusout and pointerover. With nested lists, check list.contains(li) before acting.