jQuery Event Delegation with .on(): A Practical Engineering Decision for Dynamic Content
jQuery's .on() delegation attaches one listener to a stable ancestor, handling events for current and future elements. This post covers how it works, choosing the right delegation root, selector performance, and the limitations you'll hit with non-bubbling events or stopPropagation.
27 Jun 2026, 07:05 UTC

The Problem: Event Handlers on Elements That Don't Exist Yet
You're building a UI where users add rows to a table, items to a list, or cards to a board after the initial page load. If you bind a click handler directly to each .item with $('.item').on('click', handler), every new element needs its own listener. That means rebinding after every DOM insertion — or worse, leaking memory as abandoned elements retain their handlers.
Event delegation solves this by attaching a single listener to a stable ancestor. The handler fires only when the event bubbles up from an element matching a selector filter. jQuery's .on() method makes this pattern explicit and performant, replacing the deprecated .live() and .delegate() APIs with a unified interface.
How Delegation Works Under the Hood
When you call $(ancestor).on('click', '.dynamic-child', handler), jQuery registers one click listener on ancestor. When a click occurs anywhere inside that ancestor, the event bubbles up. At the ancestor, jQuery runs the selector .dynamic-child against the event's target and each ancestor between target and the delegation root. If a match is found, your handler is invoked with a normalized Event object where:
currentTargetis the delegation root (the element you called.on()on)delegateTargetis the matched element (the one that satisfied the selector)targetremains the original source element
This normalization works consistently across browsers, including older ones where event.target behavior differs.
Choosing the Delegation Root: Closer Is Usually Better
The selector match runs on every ancestor between the event target and the delegation root. A shallow root like document or body means more checks per event. For a click on a deeply nested <button>, delegating to document forces jQuery to test the selector against every parent up the tree.
Prefer the nearest stable parent that exists at page load and contains all dynamic descendants. For a list that grows, $('#list').on('click', '.item', handler) is cheaper than $(document).on('click', '#list .item', handler) because the match stops at #list.
Selector Complexity Affects High-Frequency Events
jQuery 3.x+ uses the native Element.matches() (or matchesSelector()) where available, falling back to Sizzle for complex selectors. Simple class, tag, or ID selectors are fast. Pseudo-selectors like :visible, :has(), or attribute-contains [attr*=value] add measurable overhead on events that fire rapidly — mousemove, scroll, resize.
If you must delegate high-frequency events, keep the selector simple and the delegation root close. Better yet, avoid delegating those events entirely; use direct binding on a stable container or throttle the handler.
Worked Example: Dynamic List with Click Handling
Consider a static <ul id="list"> where users add <li class="item"> elements via an "Add" button.
<ul id="list"></ul>
<button id="add">Add Item</button>
<script>
// Delegated binding — runs once on page load
$('#list').on('click', '.item', function(e) {
// e.delegateTarget is the clicked .item
// e.currentTarget is #list
console.log('Item clicked:', $(this).text());
});
// Dynamic insertion — no rebinding needed
$('#add').on('click', function() {
const count = $('#list .item').length + 1;
$('#list').append(
$('
Clicking any .item — including those added after page load — logs its text. The single listener on #list handles all current and future matches.
To verify: open DevTools Performance panel, record a click on a dynamically added .item, and inspect the call stack. You'll see jQuery's dispatch → selector match → handler flow.
Limitations and Gotchas
Non-Bubbling Events Don't Delegate
focus, blur, load, error, mouseenter, and mouseleave do not bubble. Delegated bindings for these events will never fire. Use the bubbling equivalents focusin/focusout for focus/blur, or bind directly on the dynamic elements.
StopPropagation Breaks Delegation
If a descendant calls event.stopPropagation(), the event never reaches the delegation root. The delegated handler won't see it. This can cause silent failures in mixed direct/delegated setups.
Selector Mismatch Is Silent
Selectors are evaluated at event time, not bind time. If the DOM changes so the selector no longer matches intended elements (e.g., a class is removed), handlers simply stop firing — no error, no warning.
Many Selectors on One Ancestor Add Linear Cost
Each delegated selector on the same ancestor adds to the per-event dispatch loop. Attaching 50 different delegated click handlers to document means 50 selector checks per click. Group related handlers or use a common class with data attributes to discriminate inside a single handler.
Actionable Closing: When to Delegate, When to Bind Directly
- Delegate for low-to-moderate frequency events (
click,change,submit, custom events) on dynamic content. Use the nearest stable parent and simple selectors. - Bind directly for non-bubbling events, high-frequency events (
mousemove,scroll), or when the element set is static and small. - Profile with DevTools Performance if you suspect delegation overhead. Look for long
jQuery.event.dispatchframes and selector-matching loops. - Test non-bubbling scenarios: try delegated
focuson an input — it won't fire. Switch tofocusinto confirm the bubbling equivalent works.
Event delegation via .on() remains one of jQuery's most practical engineering decisions. It reduces memory overhead, eliminates rebinding boilerplate, and normalizes cross-browser event behavior — provided you respect its boundaries.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.