MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

JavaScript Event Delegation: Patterns and Performance

Use event delegation: bubbling, capturing, and performance benefits for large DOMs.

JavaScriptDOMEventsPerformance

By MinhVo

Introduction

Event delegation is a powerful DOM manipulation pattern that leverages event bubbling to handle events efficiently. Instead of attaching event listeners to individual elements, you attach a single listener to a parent element and use event propagation to handle events for child elements. This pattern is fundamental to building performant web applications, especially those with large or dynamically changing DOMs.

In applications like social media feeds, data tables with thousands of rows, or infinite-scroll lists, event delegation can reduce memory usage by orders of magnitude. A list with 10,000 items that attaches click handlers to each item creates 10,000 event listeners. Event delegation reduces this to a single listener. This guide explores event delegation fundamentals, advanced implementation patterns, performance benchmarks, and integration with modern frameworks.

DOM Event Handling

Understanding Event Propagation

The Three Phases of DOM Events

Every DOM event travels through three phases before being fully processed:

  1. Capturing Phase: The event travels from the window down through the DOM tree to the target element. This phase is rarely used but is essential for intercepting events before they reach their target.

  2. Target Phase: The event reaches the element that triggered it. At this point, both capturing and bubbling listeners on the target element fire.

  3. Bubbling Phase: The event bubbles back up from the target element through its ancestors to the window. This is the phase event delegation relies on.

// Demonstrate all three phases
document.querySelector('.outer').addEventListener('click', (e) => {
    console.log('Capturing phase: outer');
}, true);  // true = capturing phase
 
document.querySelector('.inner').addEventListener('click', (e) => {
    console.log('Target phase: inner');
});
 
document.querySelector('.outer').addEventListener('click', (e) => {
    console.log('Bubbling phase: outer');
});  // false (default) = bubbling phase
 
// Output when clicking .inner:
// "Capturing phase: outer"
// "Target phase: inner"
// "Bubbling phase: outer"
// Check event phase programmatically
element.addEventListener('click', (event) => {
    switch (event.eventPhase) {
        case Event.CAPTURING_PHASE:  // 1
            console.log('Capturing');
            break;
        case Event.AT_TARGET:        // 2
            console.log('Target');
            break;
        case Event.BUBBLING_PHASE:   // 3
            console.log('Bubbling');
            break;
    }
});

Events That Don't Bubble

Some events don't bubble, which means event delegation requires special handling:

EventBubbles?Delegation Strategy
clickYesStandard delegation
inputYesStandard delegation
changeYesStandard delegation
focusNoUse capturing phase or focusin
blurNoUse capturing phase or focusout
mouseenterNoUse mouseover with null check
mouseleaveNoUse mouseout with null check
scrollYesStandard delegation
loadNoAttach directly
errorNoAttach directly

For non-bubbling events, use the capturing phase:

// Delegate focus events using capturing phase
document.querySelector('.form').addEventListener('focus', (event) => {
    if (event.target.matches('input, textarea, select')) {
        event.target.classList.add('focused');
    }
}, true);  // capturing phase
 
// Or use focusin/focusout which DO bubble
document.querySelector('.form').addEventListener('focusin', (event) => {
    event.target.classList.add('focused');
});

Why Event Delegation Matters

Direct event binding creates memory overhead and management complexity:

// BAD: 10,000 individual listeners
document.querySelectorAll('.item').forEach(item => {
    item.addEventListener('click', handleClick);
});
 
// GOOD: 1 delegated listener handles all 10,000 items
document.querySelector('.list').addEventListener('click', (event) => {
    const item = event.target.closest('.item');
    if (item) handleClick(event, item);
});

Memory comparison for a list with N items:

  • Direct binding: N event listeners × ~100 bytes each = N × 100 bytes
  • Event delegation: 1 event listener + 1 handler map = ~500 bytes total

For 10,000 items, that's ~1MB vs ~500 bytes.

Performance comparison

Core Delegation Patterns

Pattern 1: Simple Selector Delegation

The most basic pattern uses matches() to check if the event target matches a selector:

document.querySelector('.list').addEventListener('click', (event) => {
    if (event.target.matches('.item')) {
        handleClick(event);
    }
});

Problem: If .item contains child elements (like a button or icon), clicking those children won't match .item even though the user intended to click the item.

Use closest() to walk up the DOM tree and find the nearest ancestor matching the selector:

document.querySelector('.list').addEventListener('click', (event) => {
    const item = event.target.closest('.item');
    if (!item) return;  // Click wasn't inside an .item
 
    handleClick(event, item);
});

This handles nested elements correctly:

<li class="item">
    <span class="title">Task 1</span>
    <button class="delete">✕</button>
</li>
// Clicking the <span> or <button> still finds the parent .item
list.addEventListener('click', (event) => {
    const item = event.target.closest('.item');
    if (!item) return;
 
    if (event.target.closest('.delete')) {
        deleteItem(item.dataset.id);
    } else {
        selectItem(item.dataset.id);
    }
});

Pattern 3: Multi-Selector Delegation

Handle multiple different element types with a single listener:

class EventDelegator {
    constructor(parentSelector) {
        this.parent = document.querySelector(parentSelector);
        this.handlers = new Map();
    }
 
    on(eventType, childSelector, handler) {
        if (!this.handlers.has(eventType)) {
            this.handlers.set(eventType, []);
 
            this.parent.addEventListener(eventType, (event) => {
                const handlers = this.handlers.get(eventType);
 
                for (const { selector, handler } of handlers) {
                    const target = event.target.closest(selector);
                    if (target && this.parent.contains(target)) {
                        handler.call(target, event, target);
                        break;  // First match wins
                    }
                }
            });
        }
 
        this.handlers.get(eventType).push({
            selector: childSelector,
            handler,
        });
    }
 
    off(eventType, childSelector) {
        const handlers = this.handlers.get(eventType);
        if (handlers) {
            const index = handlers.findIndex(h => h.selector === childSelector);
            if (index !== -1) handlers.splice(index, 1);
        }
    }
 
    destroy() {
        this.handlers.clear();
        // Note: in production, store the listener reference and remove it
    }
}
 
// Usage
const delegator = new EventDelegator('.todo-list');
delegator.on('click', '.todo-item', (event, element) => {
    element.classList.toggle('completed');
});
delegator.on('click', '.delete-btn', (event, element) => {
    element.closest('.todo-item').remove();
});
delegator.on('dblclick', '.todo-item', (event, element) => {
    startEditing(element);
});

Pattern 4: Data-Attribute Action Delegation

Use data-* attributes to define actions declaratively:

<div class="toolbar">
    <button data-action="save" data-id="123">Save</button>
    <button data-action="delete" data-id="123">Delete</button>
    <button data-action="share" data-id="123">Share</button>
</div>
const actions = {
    save: (target) => saveItem(target.dataset.id),
    delete: (target) => deleteItem(target.dataset.id),
    share: (target) => shareItem(target.dataset.id),
};
 
document.querySelector('.toolbar').addEventListener('click', (event) => {
    const actionElement = event.target.closest('[data-action]');
    if (!actionElement) return;
 
    const action = actionElement.dataset.action;
    if (actions[action]) {
        actions[action](actionElement);
    }
});

Handling Dynamic Content

Event delegation's greatest strength is handling dynamically added elements without any extra code:

const list = document.querySelector('.list');
 
// This single listener handles ALL items, including ones added later
list.addEventListener('click', (event) => {
    const item = event.target.closest('.item');
    if (!item) return;
    console.log('Clicked:', item.textContent);
});
 
// Add new items — no listener updates needed
function addItem(text) {
    const item = document.createElement('li');
    item.className = 'item';
    item.textContent = text;
    list.appendChild(item);
}

Compare with direct binding, which requires updating listeners every time the DOM changes:

// Direct binding: must re-attach listeners after DOM changes
function addItem(text) {
    const item = document.createElement('li');
    item.className = 'item';
    item.textContent = text;
    list.appendChild(item);
    item.addEventListener('click', handleClick);  // Must do this every time!
}

Advanced Patterns

Custom Event System with Delegation

Combine event delegation with a custom event emitter for clean component communication:

class EventEmitter {
    constructor() {
        this.events = {};
    }
 
    on(event, callback) {
        if (!this.events[event]) this.events[event] = [];
        this.events[event].push(callback);
        return () => {
            this.events[event] = this.events[event].filter(cb => cb !== callback);
        };
    }
 
    emit(event, ...args) {
        (this.events[event] || []).forEach(cb => cb(...args));
    }
 
    once(event, callback) {
        const unsub = this.on(event, (...args) => {
            unsub();
            callback(...args);
        });
        return unsub;
    }
}
 
// Bridge DOM delegation to custom events
const emitter = new EventEmitter();
 
document.querySelector('.container').addEventListener('click', (event) => {
    const action = event.target.dataset.action;
    if (action) emitter.emit(action, event.target, event);
});
 
emitter.on('delete', (target) => {
    target.closest('.item').remove();
    showToast('Item deleted');
});
 
emitter.on('edit', (target) => {
    openEditModal(target.closest('.item').dataset.id);
});

Delegated Form Handling

class DelegatedForm {
    constructor(formSelector) {
        this.form = document.querySelector(formSelector);
        this.validators = new Map();
        this.submitters = new Map();
        this.setupDelegation();
    }
 
    setupDelegation() {
        // Delegated input validation
        this.form.addEventListener('input', (event) => {
            const { name } = event.target;
            if (this.validators.has(name)) {
                const error = this.validators.get(name)(event.target.value);
                this.showError(name, error);
            }
        });
 
        // Delegated form submission
        this.form.addEventListener('submit', async (event) => {
            event.preventDefault();
            const data = Object.fromEntries(new FormData(this.form).entries());
 
            let hasErrors = false;
            for (const [name, validator] of this.validators) {
                const error = validator(data[name]);
                if (error) { this.showError(name, error); hasErrors = true; }
            }
            if (hasErrors) return;
 
            const action = event.submitter?.dataset.action || 'default';
            if (this.submitters.has(action)) {
                await this.submitters.get(action)(data);
            }
        });
    }
 
    addValidator(fieldName, fn) { this.validators.set(fieldName, fn); }
    addSubmitter(action, fn) { this.submitters.set(action, fn); }
 
    showError(fieldName, message) {
        const el = this.form.querySelector(`[data-error="${fieldName}"]`);
        if (el) { el.textContent = message || ''; el.style.display = message ? 'block' : 'none'; }
    }
}

Performance Benchmarks

Memory Usage: Delegation vs Direct Binding

// Benchmark: 10,000 list items
function benchmarkDirectBinding(count) {
    const list = document.createElement('ul');
    const start = performance.now();
 
    for (let i = 0; i < count; i++) {
        const li = document.createElement('li');
        li.textContent = `Item ${i}`;
        li.addEventListener('click', () => console.log(i));
        list.appendChild(li);
    }
 
    return { time: performance.now() - start, listeners: count };
}
 
function benchmarkDelegation(count) {
    const list = document.createElement('ul');
    const start = performance.now();
 
    list.addEventListener('click', (event) => {
        const item = event.target.closest('li');
        if (item) console.log(item.textContent);
    });
 
    for (let i = 0; i < count; i++) {
        const li = document.createElement('li');
        li.textContent = `Item ${i}`;
        list.appendChild(li);
    }
 
    return { time: performance.now() - start, listeners: 1 };
}
 
// Results (approximate):
// Direct binding:  450ms setup, 800KB memory for 10,000 items
// Delegation:       15ms setup,   2KB memory for 10,000 items

Event Dispatch Speed

// Benchmark: clicking 1,000 items
function benchmarkClickSpeed(list, count) {
    const items = list.querySelectorAll('li');
    const start = performance.now();
 
    for (let i = 0; i < count; i++) {
        items[i % items.length].click();
    }
 
    return performance.now() - start;
}
 
// Results (approximate):
// Direct binding:  ~50ms for 1,000 clicks (direct handler invocation)
// Delegation:      ~65ms for 1,000 clicks (closest() traversal overhead)

The slight per-click overhead of closest() is negligible compared to the massive memory savings and simplified DOM update code.

Passive Event Listeners

For scroll and touch events, use passive listeners to avoid blocking the browser's compositor:

// Passive listeners tell the browser this handler won't call preventDefault()
element.addEventListener('scroll', handler, { passive: true });
element.addEventListener('touchmove', handler, { passive: true });
element.addEventListener('wheel', handler, { passive: true });
 
// Combined with delegation
document.querySelector('.scroll-container').addEventListener('scroll',
    throttle((event) => {
        checkInfiniteScroll(event.target);
    }, 100),
    { passive: true }
);

Throttling and Debouncing

function throttle(fn, delay) {
    let lastCall = 0;
    return function (...args) {
        const now = Date.now();
        if (now - lastCall >= delay) {
            lastCall = now;
            return fn.apply(this, args);
        }
    };
}
 
function debounce(fn, delay) {
    let timeoutId;
    return function (...args) {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn.apply(this, args), delay);
    };
}
 
// Delegated scroll with throttling
list.addEventListener('scroll', throttle((event) => {
    if (event.target.scrollHeight - event.target.scrollTop - event.target.clientHeight < 100) {
        loadMoreItems();
    }
}, 100), { passive: true });
 
// Delegated input with debouncing
form.addEventListener('input', debounce((event) => {
    if (event.target.matches('.search-input')) {
        performSearch(event.target.value);
    }
}, 300));

Event Delegation in Modern Frameworks

React's Synthetic Event System

React implements its own event delegation internally. Since React 17, all events are delegated to the root DOM container (not document):

// React delegates this click to the root element internally
function TodoList({ items }) {
    // React handles delegation automatically
    // But understanding it helps debug issues
    const handleClick = (e) => {
        // e.stopPropagation() here only stops React's synthetic events
        // It does NOT stop native DOM event propagation
        e.stopPropagation();  // Stops React propagation
    };
 
    return (
        <ul>
            {items.map(item => (
                <li key={item.id} onClick={handleClick}>
                    {item.text}
                </li>
            ))}
        </ul>
    );
}

When mixing React with vanilla JS event listeners, be aware that stopPropagation() in React doesn't stop native DOM listeners and vice versa.

Vue's Event Handling

Vue handles events per-element but provides modifiers for common delegation patterns:

<!-- Vue provides .capture, .passive, .once modifiers -->
<div @click.capture="handleCapture">
    <button @click.stop="handleClick">Click</button>
</div>
 
<!-- For true delegation in Vue, use a directive -->
<template>
    <ul v-delegation:click="{ selector: '.item', handler: onItemClick }">
        <li v-for="item in items" :key="item.id" class="item">
            {{ item.text }}
        </li>
    </ul>
</template>

Web Components and Shadow DOM

Event delegation works within Shadow DOM, but events that cross shadow boundaries need special handling:

class MyComponent extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
            <div class="container">
                <slot></slot>
            </div>
        `;
 
        // Delegation within shadow DOM works normally
        this.shadowRoot.querySelector('.container').addEventListener('click', (e) => {
            const item = e.target.closest('.item');
            if (item) this.handleItemClick(item);
        });
    }
}

Events that cross shadow boundaries are retargeted (their target changes to the host element). Use event.composedPath() to get the full path including shadow DOM nodes.

Best Practices

  1. Always use closest() instead of matches(): closest() walks up the DOM tree, handling nested child elements correctly.

  2. Check for null returns: closest() returns null if no match is found. Always guard against this.

  3. Avoid stopPropagation(): It breaks other event handlers on the same elements. Use specific selectors or flags instead.

  4. Use passive listeners for scroll/touch: This tells the browser the handler won't call preventDefault(), enabling smooth scrolling.

  5. Limit delegation depth: Don't delegate on document or body unless necessary. Use the closest common ancestor of the target elements.

  6. Clean up listeners: When components unmount, remove delegated listeners to prevent memory leaks.

  7. Use data attributes for actions: data-action, data-id, and other data attributes make delegation code declarative and maintainable.

Common Pitfalls

PitfallImpactSolution
Using event.target instead of closest()Misses nested child elementsAlways use closest()
Delegating on documentPerformance overhead, event conflictsUse specific container element
Forgetting null check on closest()Runtime TypeErrorAlways check return value
Using stopPropagation()Breaks other handlersUse specific selectors instead
Not handling non-bubbling eventsEvents silently ignoredUse capturing phase or focusin/focusout
Memory leaks from unremoved listenersSlow memory growth over timeRemove listeners when components unmount

Testing Strategies

describe('Event Delegation', () => {
    let container, delegator;
 
    beforeEach(() => {
        container = document.createElement('div');
        container.innerHTML = `
            <ul class="list">
                <li class="item" data-id="1">Item 1</li>
                <li class="item" data-id="2">Item 2</li>
                <li class="item" data-id="3">Item 3</li>
            </ul>
        `;
        document.body.appendChild(container);
    });
 
    afterEach(() => {
        document.body.removeChild(container);
    });
 
    test('handles clicks on child elements', () => {
        const handler = jest.fn();
        container.querySelector('.list').addEventListener('click', (e) => {
            const item = e.target.closest('.item');
            if (item) handler(item.dataset.id);
        });
 
        container.querySelector('[data-id="2"]').click();
        expect(handler).toHaveBeenCalledWith('2');
    });
 
    test('handles dynamically added elements', () => {
        const handler = jest.fn();
        container.querySelector('.list').addEventListener('click', (e) => {
            const item = e.target.closest('.item');
            if (item) handler(item.dataset.id);
        });
 
        // Add element AFTER listener is attached
        const newItem = document.createElement('li');
        newItem.className = 'item';
        newItem.dataset.id = '4';
        newItem.textContent = 'Item 4';
        container.querySelector('.list').appendChild(newItem);
 
        newItem.click();
        expect(handler).toHaveBeenCalledWith('4');
    });
 
    test('ignores clicks outside delegated elements', () => {
        const handler = jest.fn();
        container.querySelector('.list').addEventListener('click', (e) => {
            const item = e.target.closest('.item');
            if (item) handler();
        });
 
        container.querySelector('.list').click();
        expect(handler).not.toHaveBeenCalled();
    });
 
    test('handles nested elements correctly', () => {
        const handler = jest.fn();
        container.querySelector('.list').addEventListener('click', (e) => {
            const item = e.target.closest('.item');
            if (item) handler(item.dataset.id);
        });
 
        // Add nested element
        const item = container.querySelector('[data-id="1"]');
        const button = document.createElement('button');
        button.className = 'delete';
        button.textContent = 'Delete';
        item.appendChild(button);
 
        button.click();
        expect(handler).toHaveBeenCalledWith('1');
    });
});

Conclusion

Event delegation is essential for efficient DOM event handling. The pattern reduces memory usage, simplifies code for dynamic content, and provides a clean abstraction for handling user interactions at scale.

Key takeaways:

  1. Attach listeners to parent elements, not individual children — reduces memory from O(n) to O(1)
  2. Use closest() for reliable target matching — handles nested elements correctly
  3. Event delegation handles dynamic content automatically — no re-attachment needed
  4. Apply passive listeners for scroll/touch performance — avoids blocking the compositor
  5. Use data attributes for declarative action handling — makes code maintainable
  6. Clean up delegated listeners to prevent memory leaks — especially in SPAs
  7. Handle non-bubbling events with capturing phase — for focus, blur, mouseenter, mouseleave

Practice event delegation on MDN and explore advanced patterns in JavaScript.info.