Profiling & Web Performance

Expert · 18 min read · ▶ live playground · ✦ checkpoint

Performance work is not "make the code look faster." It is a loop: choose a user-visible problem, measure it with the right tool, change one thing, and measure again. In this lesson you will use DevTools as evidence, spot the leaks that keep pages heavy, read Core Web Vitals as user-experience signals, and treat bundle size as a budget instead of a surprise.

Start with the question

After the V8 lesson, it is tempting to hunt for clever engine tricks. Real performance work starts higher up: what did the user experience? A button felt sticky, a route took too long to become useful, scrolling felt janky, or the page got heavier each time a panel opened.

The question picks the tool:

  • Performance panel — records what the browser did while a user action happened: scripting, rendering, painting, network, and main-thread work.
  • Memory panel — shows whether objects remain reachable after they should be gone, which is how you prove or disprove leaks.
  • Build output / bundle analyzer — shows what JavaScript and assets you ship before the page can run.
  • Web Vitals tooling — summarizes user-facing loading, interaction, and layout stability signals.

The beginner trap is optimizing a hunch. If you do not know which interaction, page, device class, and tool reading you are improving, you are probably polishing the wrong part.

The Performance panel

The DevTools Performance panel is a recording tool. It does not tell you "your app is slow because JavaScript"; it gives you a timeline so you can ask better questions.

Performance panel workflow
 
1. Open the page in the state users actually hit.
2. Start a recording.
3. Perform one focused interaction: open a menu, submit a form, change a filter.
4. Stop the recording.
5. Read from user action outward:
   - Which task handled the interaction?
   - Which functions dominated scripting work?
   - Did layout or paint cluster around the same action?
   - Did network work block useful UI?
6. Change one thing, then record the same interaction again.

Notice what is missing: a random benchmark pasted into the console. Console timing can be useful for tiny experiments, but page performance is a full-browser story. The Performance panel shows the main thread, rendering work, and the surrounding context that a micro-benchmark hides.

The habit to build is baseline first. A changelog sentence like "optimized the dashboard" is not evidence unless you can attach it to a named interaction and a comparable recording.

The Memory panel

The Memory panel answers a different question: after a thing goes away, did the references to it go away too? JavaScript's garbage collector can collect unreachable objects, but it cannot collect values you accidentally keep reachable from a listener list, timer callback, module-level array, cache, or closure.

Memory panel leak check
 
1. Start from a fresh page state.
2. Take a baseline heap snapshot.
3. Repeat one lifecycle: open the UI, use it, close it.
4. Take another snapshot.
5. Repeat the same lifecycle again.
6. Compare what stays retained:
   - Listener count grows after every open/close cycle.
   - Timer callbacks remain reachable after the UI is gone.
   - Detached DOM tree appears in memory instead of disappearing.

A detached DOM node is a browser object that used to be in the page but is no longer connected to the document. Detached nodes are not automatically leaks; they become leaks when some JavaScript reference still keeps them alive after the UI no longer needs them.

Here is the same leak shape without using the DOM. This fake channel stands in for a browser event target:

class Channel {
  #listeners = new Set();
 
  subscribe(listener) {
    this.#listeners.add(listener);
    return () => this.#listeners.delete(listener);
  }
 
  emit(message) {
    for (const listener of this.#listeners) {
      listener(message);
    }
  }
 
  get listenerCount() {
    return this.#listeners.size;
  }
}
 
function openPanel(channel) {
  channel.subscribe((message) => {
    console.log(`panel heard: ${message}`);
  });
}
 
const channel = new Channel();
 
openPanel(channel);
openPanel(channel);
 
channel.emit("refresh");
console.log("listeners retained:", channel.listenerCount);

panel heard: refresh, panel heard: refresh, then listeners retained: 2 — the exact wrong output is duplicate work plus retained listeners after repeated setup.

The fix is not magic. Keep the unsubscribe function, stop timers, abort pending work, and release references as part of the same lifecycle that created them:

class Channel {
  #listeners = new Set();
 
  subscribe(listener) {
    this.#listeners.add(listener);
    return () => this.#listeners.delete(listener);
  }
 
  emit(message) {
    for (const listener of this.#listeners) {
      listener(message);
    }
  }
 
  get listenerCount() {
    return this.#listeners.size;
  }
}
 
function mountPanel(channel) {
  const unsubscribe = channel.subscribe((message) => {
    console.log(`panel heard: ${message}`);
  });
 
  return () => {
    unsubscribe();
  };
}
 
const channel = new Channel();
const cleanup = mountPanel(channel);
 
channel.emit("refresh");
cleanup();
console.log("listeners retained:", channel.listenerCount);

panel heard: refresh, then listeners retained: 0 — the setup returned cleanup, and the lifecycle ended cleanly.

Try it — cleanup as a habit

This playground uses pure JavaScript, not the DOM. The "timer" is a fake handle so the pattern is deterministic: create resources in one place, register cleanup immediately, then run cleanup when the feature unmounts or closes.

javascript — live consolereal engine
⌘/Ctrl + Enter to run

In a real browser component, the same pattern wraps addEventListener/removeEventListener, setInterval/clearInterval, observers with .disconnect(), and pending async work with AbortController. The names change; the lifecycle discipline does not.

Web Vitals and bundle budgets

Core Web Vitals are user-experience signals, not JavaScript trivia. LCP, Largest Contentful Paint, asks when the main visible content appears useful. INP, Interaction to Next Paint, asks whether interactions respond promptly. CLS, Cumulative Layout Shift, asks whether the page jumps around unexpectedly. This course keeps them at that level because scoring thresholds and reporting tools belong in current performance tooling, not memorized from a lesson.

Bundle-size budgets are the same discipline applied before runtime. A bundle budget is an agreed limit for what a route may ship: JavaScript, CSS, images, fonts, and other assets. The exact limit depends on the app and audience. The professional move is to make the budget visible in build output or CI so "just one more package" has to justify its cost.

Bundle budget review
 
For the route or feature being changed:
- Which assets are shipped before the user can interact?
- Did this change add a new dependency or large asset?
- Can the feature lazy-load after the first useful screen?
- Is the cost worth the user-visible value?
- If the budget is exceeded, is the exception documented and deliberate?

That last line matters. Budgets are not a moral law; they are an early warning system. Sometimes a charting library, editor, map, or 3D viewer earns its weight. But it should earn it in the open.

Measure before optimizing

A reliable performance loop is small and repeatable:

Performance loop
 
1. Name the user-visible problem.
2. Pick the tool that sees that problem.
3. Capture a baseline.
4. Change one thing.
5. Capture the same scenario again.
6. Keep the change only if the evidence and product behavior both improve.

This mindset also protects you from the V8 lesson's shadow side. Hidden classes, inline caches, and deoptimization are useful mental models, but users do not experience "a hidden class." They experience delayed content, sticky input, jumpy layout, battery drain, or a tab that gets heavier the longer it stays open.

You now have the practical performance toolbox: Performance recordings for main-thread work, Memory snapshots for retained objects, Web Vitals for user-facing signals, and bundle budgets for shipped weight. Next, we zoom out from engines and tools to the language itself: how TC39 proposals move, how yearly JavaScript releases happen, and how to evaluate new-feature hype without drowning in it.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion