Performance and Diagnostics
Measuring before optimizing, recognizing algorithmic complexity in everyday code, throttling/debouncing expensive work, and finding the memory-leak sources unique to long-lived JavaScript apps.
By the end of this lesson, you can
- Explain why measuring before optimizing matters, and distinguish lab data from field data
- Recognize common algorithmic complexity classes in everyday array/loop code
- Apply throttling or debouncing to expensive work triggered by a high-frequency event
- Identify the four classic sources of a JavaScript memory leak
Why it matters
Performance work done without measuring first is guesswork — effort frequently lands on code that was never the actual bottleneck. This lesson closes the Intermediate tier with how to measure correctly, recognize the everyday code shapes that scale badly, and find the memory-leak patterns specific to long-lived JavaScript applications.
Measure first
| Kind | Collected | Good for |
|---|---|---|
| Lab data | A controlled environment, on demand (DevTools' own profiler) | Deep investigation, reproducible before/after comparison |
| Field data | Real users, real devices, real networks, continuously | Knowing what users actually experience — a lab machine isn't representative |
The browser’s Performance panel records exactly what ran and how long each part took — a flame
chart traceable down to individual function calls. The Network panel’s waterfall view shows request
timing and ordering; a memory snapshot captures what’s currently allocated, comparable across two
points in time to spot what grew. Node has its own --prof profiling flag and inspector-based
tooling for the equivalent server-side investigation.
Algorithmic complexity in everyday code
Recognizing complexity doesn’t require formal analysis for most everyday code — just noticing when a loop is nested inside another loop over related data:
// O(n²) — for each of n orders, .find() scans up to m customers
function attachCustomerNames(orders, customers) {
return orders.map((order) => ({
...order,
customerName: customers.find((c) => c.id === order.customerId)?.name,
}));
}
// O(n + m) — build a lookup once, then each order is an O(1) lookup
function attachCustomerNamesFast(orders, customers) {
const byId = new Map(customers.map((c) => [c.id, c.name]));
return orders.map((order) => ({ ...order, customerName: byId.get(order.customerId) }));
}Both versions are “just calling array methods” — nothing about the code looks dangerous. The
difference only shows up at scale: for a handful of orders and customers, both run instantly; for
tens of thousands of each, the nested-scan version becomes the dominant cost, while the Map-based
version stays fast. Recognizing “a .find()/.filter() call inside a .map()/loop over a related
collection” as a shape worth checking is more valuable day to day than memorizing Big O notation
formally.
Throttling and debouncing expensive work
A high-frequency event (scroll, resize, input) can fire far more often than the resulting work
actually needs to run:
function throttle(fn, intervalMs) {
let lastRun = 0;
return function (...args) {
const now = Date.now();
if (now - lastRun >= intervalMs) {
lastRun = now;
fn(...args);
}
};
}
window.addEventListener('scroll', throttle(updateScrollIndicator, 100)); // runs at most every 100ms
Throttle limits a function to running at most once per interval, regardless of how often it’s
triggered. Debounce — recall the debounce exercise from Asynchronous JavaScript in
Depth — instead waits for a pause in triggering
before running once. Throttle suits “keep this updated roughly continuously, but not on every
single event” (a scroll indicator); debounce suits “only run once the user has stopped” (search-as-
you-type, resize-triggered layout recalculation).
DOM batching and code loading
Reading a layout-dependent property (element.offsetHeight) immediately after writing a style
forces the browser to recalculate layout synchronously, right then — doing this repeatedly in a
loop (“layout thrashing”) is a well-known performance trap:
// Layout thrashing — read, write, read, write forces a layout recalculation each time
elements.forEach((el) => {
const height = el.offsetHeight; // read
el.style.height = height * 2 + 'px'; // write
});
// Batched — all reads first, then all writes, one layout recalculation total
const heights = elements.map((el) => el.offsetHeight); // all reads
elements.forEach((el, i) => (el.style.height = heights[i] * 2 + 'px')); // all writes
Lazy-loading code that isn’t needed for the initial view (a route not yet visited, a rarely-used
feature) via dynamic import() keeps the initial bundle smaller, deferring cost to when it’s
actually needed rather than paying it upfront for every visitor.
Memory leaks, specifically
| Source | How it leaks |
|---|---|
| Forgotten event listener | addEventListener with no matching removeEventListener keeps referenced data alive indefinitely |
| Uncleared timer | setInterval that's never cleared keeps its closure — and everything it captured — alive forever |
| Long-lived closure | A closure attached to something that outlives its intended purpose (recall Execution Model, Scope, and Closures) |
| Detached DOM | A removed element still referenced from JavaScript (a cached lookup, a closure) stays in memory even though it's no longer on the page |
Check your understanding
Or reveal the answer without checking
Answer:Build a Map keyed by the lookup field once, then look up by key inside the .map() — O(n + m) instead of O(n × m)
The nested .find() inside .map() is the O(n × m) shape from this lesson. Building a Map (or plain object) lookup once, ahead of time, turns each inner lookup into O(1), making the whole operation O(n + m) — the fix that actually changes the growth rate, not just a constant-factor tweak.
Or reveal the answer without checking
Answer:Debounce
Debounce waits for a pause in triggering before running — exactly "wait until the user stops typing." Throttle would still run periodically during continuous typing, which isn't what's wanted here.
Practice: warm-up
This code reads and writes layout-dependent styles in a loop, causing layout thrashing. Rewrite it to batch all reads before all writes.
boxes.forEach((box) => {
const width = box.offsetWidth;
box.style.width = width + 10 + 'px';
});
Stuck? Reveal one hint at a time.
Hint 1
Separate the loop into two passes: one that only reads (collecting results into an array), one that only writes.
Hint 2
map() is a natural fit for the read pass, since it produces exactly the array of results the write pass needs.
Reveal the batched version
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
const widths = boxes.map((box) => box.offsetWidth); // all reads first
boxes.forEach((box, i) => (box.style.width = widths[i] + 10 + 'px')); // then all writesPractice: apply it
Write throttle(fn, intervalMs) (if you haven’t already from the lesson body, reproduce it from
memory) and use it to limit a window.addEventListener('resize', ...) handler to running at most
once every 200ms.
Stuck? Reveal one hint at a time.
Hint 1
Track the last time fn actually ran, and only allow another run once intervalMs has passed since then.
Hint 2
Date.now() minus the last-run timestamp, compared against intervalMs, is the whole check.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
function throttle(fn, intervalMs) {
let lastRun = 0;
return function (...args) {
const now = Date.now();
if (now - lastRun >= intervalMs) {
lastRun = now;
fn(...args);
}
};
}
window.addEventListener('resize', throttle(recalculateLayout, 200));Summary
- Measure before optimizing — lab data (a controlled profiler run) is for investigation; field data (real users, real devices) is for confirming an improvement actually matters.
- A
.find()/.filter()nested inside a.map()/loop over a related collection is the everyday shape of accidental O(n²) — aMap-based lookup built once usually fixes it. - Throttle limits a function to running at most once per interval; debounce waits for a pause before running once — pick based on whether “roughly continuous but capped” or “only after it stops” is actually wanted.
- Reading a layout-dependent property immediately after a style write forces synchronous layout recalculation; batching all reads before all writes avoids repeating that cost in a loop.
- Forgotten event listeners, uncleared timers, long-lived closures, and detached DOM elements still referenced from JavaScript are the four classic sources of a memory leak — a memory-snapshot comparison across repeated identical actions is the standard way to confirm one.