Performance, event delegation and memory

Cache selections, delegate from a stable root, avoid layout thrashing, and find the leak patterns that grow a long-lived page.

Selections and layout

// 1. Cache inside a scope. A bare $('.row') re-queries the document every time.
const $tbody = $('#tbody');
const $rows = $tbody.find('.row');            // one query, reused

// 2. Do not query inside a loop over a collection.
// Wrong: O(n) document queries
// $('.row').each(function () {
//   $(this).find('.total').text('0');          // fine — scoped
//   $('.grand-total').text('0');               // re-queries the whole document each time
// });

// 3. Batch DOM writes. Reading and writing alternately forces a layout each time.
// Wrong: read, write, read, write
// $rows.each(function () { $(this).height($(this).width() / 2); });
//
// Right: read all, then write all
const widths = $rows.map((_, el) => el.getBoundingClientRect().width).get();
$rows.each((i, el) => { el.style.height = (widths[i] / 2) + 'px'; });

// 4. Detach before bulk edits, reattach once.
const $fragment = $tbody.detach();
$fragment.find('.row').each(function () { $(this).addClass('is-compact'); });
$('#table').append($fragment);
OperationCostGuidance
$('.x') in a loopA document query each timeQuery once, store the result
$(this) inside eachCreates a new wrapper objectAcceptable, but this is already the element
.html(string) with a big stringParses and insertsBatch into a fragment or a detached container
.width() in a loopForces layoutRead all, then write all
.each over 5,000 rowsSlow DOM workBuild one HTML string or use a fragment
.filter(':visible')Forces layout per elementUse a class instead of a visibility check
⚠️
Pseudo-selectors such as :visible, :hidden and :animated are jQuery extensions, not CSS. They cannot use the browser's native query engine, so a selector containing one runs the whole candidate set through JavaScript and touches layout. Replace them with a class you control.

Event delegation

// Wrong: one handler per row, bound at render time. Rows added later have none.
$('.row .delete').on('click', handleDelete);

// Right: one handler on a stable ancestor, works for future rows.
$('#tbody').on('click', '.row .delete', handleDelete);

// The handler receives the delegated event; "this" is the matched element.
function handleDelete(event) {
  const $button = $(this);
  const $row = $button.closest('tr');
  event.preventDefault();
  deleteRow($row.data('id'));
}

// Namespace delegated handlers so a partial teardown is possible.
$('#tbody').on('click.app', '.row .delete', handleDelete);
$('#tbody').off('.app');                     // removes only this namespace

// Delegate from the closest stable ancestor, not document, unless the content
// genuinely moves between containers.
$('#modal-body').on('keydown.edit', 'input', handleKeys);
SituationApproachReason
Rows added or removed dynamicallyDelegate from the table bodyNo rebinding needed
A long list with a handler per itemDelegate from the containerOne handler instead of thousands
A component that can appear twiceNamespace the eventsoff('.name') is safe
Content in a modal rendered laterDelegate from the modal rootThe trigger may not exist at bind time
A truly global shortcutDelegate from documentThe only case where document is right
// A common leak: a delegated handler on document that grows with each render.
// Wrong
function render(items) {
  items.forEach((item) => {
    $(document).on('click', '.item-' + item.id, () => select(item.id));   // new handler every render
  });
}

// Right: one handler, data read from the element
$(document).on('click.select', '.item', function () {
  select($(this).data('id'));
});

Memory and teardown

// 1. .remove() is the correct way to delete an element: it also discards
//    jQuery's data and bound handlers for the subtree.
$('#row-42').remove();

// 2. .detach() keeps them, which is what you want for a temporary move.
const $panel = $('#panel').detach();
$('#other-container').append($panel);        // handlers still work

// 3. Remove jQuery bound timers yourself — they do not belong to any element.
const intervalId = setInterval(poll, 5000);
$(window).on('unload.teardown', () => clearInterval(intervalId));
// or, for a component-style teardown:
function destroy() { clearInterval(intervalId); $(document).off('.widget'); }

// 4. Unbind global listeners when their owner is removed.
const $widget = $('#widget');
$widget.on('remove.widget', function () { $(window).off('.widget'); });
$widget.find('[data-close]').on('click', function () { $widget.trigger('remove.widget').remove(); });

// 5. Do not hold large detached trees in a closure. If you must keep one,
//    keep plain data rather than a jQuery collection with handlers attached.
let cache = null;
function warm(html) {
  cache = html;                     // a string, cheap
  // cache = $(html);               // a detached DOM tree with handlers: expensive
}
  • jQuery stores its event and data cache in a numeric key on the element plus a global map. Removing an element with innerHTML = '' bypasses that cleanup and leaks the entries.
  • Global selectors on window and document in a page that never unloads are only a problem if the handlers accumulate — which they do when the same construct runs on every route change.
  • Timers are the most common leak in a jQuery codebase because nothing ties them to an element. Give every component a destroy() and call it.
  • Profiling method: take a heap snapshot, interact for a minute, take another, and compare the object counts for jQuery and for detached HTMLDivElement.
// A small teardown convention that scales across a page
const components = [];

function mount(name, setup) {
  const api = setup() || {};
  components.push({ name, destroy: api.destroy || (() => {}) });
  return api;
}

function unmountAll() {
  while (components.length) {
    const { name, destroy } = components.pop();
    try { destroy(); } catch (error) { console.error('teardown failed:', name, error); }
  }
}

// usage
mount('inbox', () => {
  const $root = $('#inbox');
  $root.on('click.app', '.row', handleRowClick);
  const timer = setInterval(refresh, 30_000);
  return { destroy() { $root.off('.app'); clearInterval(timer); } };
});

FAQ

Is jQuery slow?
No. The DOM is slow. jQuery adds a thin wrapper cost that only matters in tight loops over thousands of elements, and the fixes are the same ones you would need with vanilla code: query once, delegate, batch writes, and do not query inside a loop.
How do I find a jQuery memory leak?
Take a heap snapshot, exercise the part of the app you suspect, force garbage collection, and take another. Growing counts of detached elements or of jQuery data entries point at a removal path that used innerHTML or .empty() instead of .remove(), or a timer nobody cleared.

Events, effects and attributes Writing a jQuery plugin

Last refreshed 2026-09-18.