IntermediatePerformance and diagnostics~40 min

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

Lab data versus field data — different purposes, both necessary
KindCollectedGood for
Lab dataA controlled environment, on demand (DevTools' own profiler)Deep investigation, reproducible before/after comparison
Field dataReal users, real devices, real networks, continuouslyKnowing 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:

JavaScriptAn accidental O(n²) hiding in ordinary-looking code
// 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

Four classic JavaScript memory-leak sources
SourceHow it leaks
Forgotten event listeneraddEventListener with no matching removeEventListener keeps referenced data alive indefinitely
Uncleared timersetInterval that's never cleared keeps its closure — and everything it captured — alive forever
Long-lived closureA closure attached to something that outlives its intended purpose (recall Execution Model, Scope, and Closures)
Detached DOMA 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

A function calls .find() inside a .map() over a related array, and both arrays have around 50,000 items. What's the most effective fix?
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.

A search input triggers an expensive re-render on every keystroke, and the goal is to only re-render once the user pauses typing. Throttle or debounce?
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.

  1. Hint 1

    Separate the loop into two passes: one that only reads (collecting results into an array), one that only writes.

  2. 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 writes

Practice: 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.

  1. Hint 1

    Track the last time fn actually ran, and only allow another run once intervalMs has passed since then.

  2. 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²) — a Map-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.