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);| Operation | Cost | Guidance |
|---|---|---|
$('.x') in a loop | A document query each time | Query once, store the result |
$(this) inside each | Creates a new wrapper object | Acceptable, but this is already the element |
.html(string) with a big string | Parses and inserts | Batch into a fragment or a detached container |
.width() in a loop | Forces layout | Read all, then write all |
.each over 5,000 rows | Slow DOM work | Build one HTML string or use a fragment |
.filter(':visible') | Forces layout per element | Use 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);| Situation | Approach | Reason |
|---|---|---|
| Rows added or removed dynamically | Delegate from the table body | No rebinding needed |
| A long list with a handler per item | Delegate from the container | One handler instead of thousands |
| A component that can appear twice | Namespace the events | off('.name') is safe |
| Content in a modal rendered later | Delegate from the modal root | The trigger may not exist at bind time |
| A truly global shortcut | Delegate from document | The 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
windowanddocumentin 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
jQueryand for detachedHTMLDivElement.
// 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.Related
Events, effects and attributes Writing a jQuery plugin
Last refreshed 2026-09-18.