JavaScript Performance
Learn the most common causes of slow, janky JavaScript in the browser and the standard techniques — event delegation, debouncing, throttling, lazy loading, code splitting — used to fix them, plus how to spot memory leaks and profile real bottlenecks with DevTools.
A web page can be technically correct and still feel slow — janky scrolling, laggy typing, a UI that freezes during a big task. Most of these problems trace back to a small set of well-known causes, each with a well-known fix, which is what this lesson covers.
Unnecessary DOM updates
The DOM is comparatively expensive to touch: reading layout information (like element.offsetHeight) can force the browser to recalculate layout, and writing to the DOM (changing styles, adding elements) can trigger layout and repaint. Code that updates the DOM in a tight loop, or updates it with values that haven't actually changed, wastes this expensive work repeatedly.
The general fix is to batch changes: compute everything you need in JavaScript first, then apply all DOM writes together (for example, by building a string or DocumentFragment and inserting it once), rather than touching the DOM element-by-element inside a loop.
Event delegation
Attaching a separate click listener to every row of a 500-row table means 500 listener objects in memory, and it breaks the moment new rows are added dynamically (they have no listener at all). Event delegation exploits event bubbling: instead of listening on each child, you attach one listener to a shared parent, and inspect event.target inside the handler to figure out which child was actually interacted with.
This is both a performance win (one listener instead of many) and a correctness win (it automatically works for elements added to the DOM later, since the listener lives on the stable parent, not the dynamic children).
Debouncing and throttling
Some events fire far more often than you actually need to react to them — scroll, resize, and especially input/keyup during typing can fire dozens of times per second. Debouncing delays running a function until a pause has occurred: if the event keeps firing, the timer keeps resetting, so the function only runs once the user has stopped (ideal for search-as-you-type, where you want to wait until they pause before firing an API request).
Throttling instead guarantees a function runs at most once per fixed time interval, no matter how often the event fires (ideal for scroll-position tracking, where you want steady, regular updates rather than a single final one).
Lazy loading, code splitting, and dynamic import
Lazy loading means deferring the loading of a resource (an image, a chunk of JavaScript, a component) until it's actually needed, rather than loading everything up front — this is what IntersectionObserver-based image lazy-loading and route-based code splitting both achieve, from different angles.
Code splitting breaks a large JavaScript bundle into smaller chunks that load only when needed, instead of shipping one enormous file for the whole app on first visit. The import() function (dynamic import) is the language-level mechanism that makes this possible: unlike a static import at the top of a file, import("./module.js") returns a Promise and can be called conditionally, at any point in your code, triggering the browser to fetch that chunk only when it's actually reached.
Memory leaks
A memory leak in JavaScript happens when memory that is no longer needed is still reachable from somewhere, so the garbage collector can never reclaim it. Common causes include: forgetting to remove an event listener from an element that gets removed from the DOM, a setInterval that is never cleared, closures that unintentionally keep large objects alive, and growing global arrays/caches that nothing ever trims.
Left unchecked, leaks cause a page's memory usage to climb the longer it stays open, eventually degrading performance or crashing the tab — especially noticeable in long-lived single-page applications.
Profiling with DevTools
Rather than guessing at what's slow, the browser's DevTools Performance panel lets you record a session of real interaction and see exactly where time was spent — scripting, rendering, painting — frame by frame, plus flag long tasks that block the main thread. The Memory panel lets you take heap snapshots and compare them over time to spot objects that keep accumulating instead of being freed, which is the standard way to actually confirm and locate a memory leak rather than guessing.
Debounce and throttle implementations
debounce resets its timer on every call, so fn only runs after the calls stop for delay ms. throttle tracks the last time fn actually ran and only allows another run once interval ms have passed, regardless of how many calls happen in between.
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, interval) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
fn.apply(this, args);
}
};
}Code Examples
This is event delegation in action: a single listener on the parent list handles clicks for every delete button, present now or added in the future, instead of attaching (and having to re-attach) a listener per item. If items were added dynamically with their own individual listeners instead, every new item would need its listener wired up manually, and every removed item would need its listener cleaned up to avoid a leak.
event.target.matches("button.delete") checks which specific child element was actually clicked, from inside the one shared handler.
Without debouncing, every single keystroke would fire a network request. With it, searchApi only runs 300ms after the user stops typing, dramatically cutting down on wasted requests.
clearTimeout(timeoutId) inside debounce cancels the previous pending call every time a new keystroke arrives.
Common mistake: Calling searchApi directly on every 'input' event (with no debounce) can flood the server with requests for every intermediate keystroke, most of which are immediately made obsolete by the next one.
Instead of bundling chart.js into the main JavaScript file that every visitor downloads, dynamic import() fetches that code on demand, only when the user actually clicks to view the chart — shrinking the initial page load for everyone who never clicks it. This is the mechanism behind route-based code splitting in most modern frameworks: each route/page is its own chunk, loaded only when the user navigates there.
await import("./chart.js") returns a Promise that resolves once that separate chunk has been fetched and evaluated.
const list = document.querySelector("#todo-list");
list.addEventListener("click", (event) => {
if (event.target.matches("button.delete")) {
event.target.closest("li").remove();
}
});
// Works for ANY <li><button class="delete">...</button></li> added later,
// even though only ONE listener was ever attached, to the parent <ul>.function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delay);
};
}
function searchApi(query) {
console.log("Searching for:", query);
// fetch(`/api/search?q=${encodeURIComponent(query)}`) in a real app
}
const debouncedSearch = debounce(searchApi, 300);
const searchInput = document.querySelector("#search");
searchInput.addEventListener("input", (e) => debouncedSearch(e.target.value));// Loaded eagerly on every page, even if the user never opens the chart:
// import { renderChart } from "./chart.js";
const showChartBtn = document.querySelector("#show-chart");
showChartBtn.addEventListener("click", async () => {
showChartBtn.textContent = "Loading chart...";
const { renderChart } = await import("./chart.js"); // fetched only now
renderChart(document.querySelector("#chart-container"));
showChartBtn.remove();
});Debounce vs throttle over time
Debounce waits for a pause and fires once at the end; throttle fires at a steady maximum rate throughout, even mid-burst.
Events: | | | | | | |
time ----> a b c d e f g
Debounce(300ms): fires ONCE, 300ms after the LAST event in the burst
^ fires here (after e, once quiet)
Throttle(300ms): fires at most once per 300ms WHILE events are happening
^fires at a ^fires again ~300ms later, mid-burstImportant Rules
Batch DOM reads and writes separately rather than interleaving them in a loop, to avoid repeated forced layout recalculations.
Use event delegation for lists of similar interactive elements, especially when items are added or removed dynamically.
Debounce user-input-driven actions that should only fire after activity stops (search-as-you-type); throttle continuous events that should fire at a steady rate (scroll tracking).
Always remove event listeners, clear intervals/timeouts, and drop references to large objects you no longer need — this is how you avoid memory leaks.
Profile before optimizing — use the DevTools Performance and Memory panels to find the actual bottleneck instead of guessing.
Where this shows up
Search-as-you-type boxes use debouncing to avoid firing a request per keystroke; infinite-scroll feeds and 'back to top' buttons use throttled scroll handlers; large dashboards use dynamic import() to load a heavy charting library only for users who actually view charts; and single-page apps that run for hours (like a browser-based editor or an admin console left open all day) are exactly where uncleared timers and listeners silently accumulate into real memory leaks.
items.forEach(item => list.innerHTML += <li>${item}</li>); // re-parses the whole list every iteration const html = items.map(item => <li>${item}</li>).join(""); list.innerHTML = html; // one single DOM write
Using += with innerHTML inside a loop forces the browser to re-parse and re-render the entire growing list on every single iteration. Building the full string first and writing it once is dramatically faster.
function attachTimer(el) { setInterval(() => el.textContent = Date.now(), 1000); } // never cleared, even after el is removed function attachTimer(el) { const id = setInterval(() => el.textContent = Date.now(), 1000); return () => clearInterval(id); // caller can stop it, e.g. before removing el }
An interval that is never cleared keeps running (and keeps a reference to el alive) even after the element has been removed from the page, which is a classic memory leak in long-lived apps.
[Multiple Choice] You want a 'live search' input to send an API request only after the user has paused typing for a moment, not on every keystroke. Which technique fits? A. Event delegation B. Throttling C. Debouncing D. Code splitting
C. Debouncing — Debouncing delays execution until a pause in events has occurred, resetting its timer on every new event — exactly the behavior needed for a search box that should wait until typing stops.
[True / False] Forgetting to call clearInterval() on a setInterval() tied to a removed DOM element can cause a memory leak. A. True B. False
A. True — True. The interval keeps running and keeps a reference to the element (and anything it closes over) alive in memory, even after the element is removed from the page, preventing garbage collection.
You have a table where clicking a 'favorite' button (class .fav-btn) on any of its many rows should toggle a 'favorited' class on that row. New rows can be added to the table after page load. Write the event handling code using delegation so it works for all current and future rows with a single listener.
const table = document.querySelector("#items-table"); table.addEventListener("click", (event) => { if (!event.target.matches(".fav-btn")) return; const row = event.target.closest("tr"); row.classList.toggle("favorited"); }); // Any row added to #items-table later, with its own .fav-btn, works automatically — // no new listener needs to be attached to it.
Attach one listener to the table's parent container, check event.target.matches(".fav-btn") inside it, then use event.target.closest("tr") to find the row.