โœฆ StealThis .dev
Plugins Medium

Performance Profiling in the Browser

A practical cheatsheet for profiling web pages with Chrome DevTools, covering flame charts, long tasks, layout thrashing, Core Web Vitals, and memory snapshots.

devtools javascript
Targets: Markdown

Code

Performance Profiling in the Browser โ€” Cheatsheet

A field guide to the Chrome DevTools Performance and Memory panels. Use it to record a trace, read the flame chart, find long tasks and layout thrashing, measure Core Web Vitals, and capture heap snapshots. Everything here uses real, current DevTools behavior and the standard web-vitals / PerformanceObserver APIs โ€” no invented flags.

Opening the panels

Open DevTools:        F12  or  Cmd+Opt+I (macOS) / Ctrl+Shift+I (Win/Linux)
Performance panel:    Cmd+Opt+I โ†’ "Performance" tab
Memory panel:         Cmd+Opt+I โ†’ "Memory" tab
Command menu:         Cmd+Shift+P โ†’ type "Show Performance"
Start/stop recording: Ctrl+E (while Performance panel is focused)
Record + reload:      click the circular-arrow "reload" button in the Performance panel

Tip: always profile in an Incognito window with extensions disabled so extension work does not pollute the trace.

Recording a trace (Performance panel)

1. Performance tab โ†’ gear icon to expose capture settings.
2. Set "CPU" throttling: 4x or 6x slowdown to simulate mid-tier mobile.
3. Set "Network" throttling: "Slow 4G" / "Fast 4G" for realistic load.
4. Tick "Screenshots" to get a filmstrip; tick "Memory" to overlay the JS heap line.
5. For page load:  click the reload button (records load + reload).
   For interaction: click record (โ—), do the interaction, click stop (โ– ).
6. Keep recordings short (3โ€“8s). Long traces are slow to analyze.

Programmatic markers show up on the Timings track of the trace:

// User Timing API โ€” these appear as named bars in the flame chart
performance.mark("checkout:start");
doExpensiveWork();
performance.mark("checkout:end");
performance.measure("checkout", "checkout:start", "checkout:end");

// Read it back
const [m] = performance.getEntriesByName("checkout");
console.log(m.duration.toFixed(1), "ms");

In modern Chrome you can also add custom tracks with console.timeStamp extension args, but performance.measure is the portable, standard approach.

Reading the flame chart

The Main track is a flame chart: width = time spent, vertical stacking = call depth. The top bar is the entry point; children below are the functions it called.

Wide bars            = expensive (focus here first)
Tall stacks          = deep call chains
Red corner triangle  = a "long task" (>50ms) โ€” blocks the main thread
Yellow blocks        = scripting (JS)
Purple blocks        = rendering (style/layout)
Green blocks         = painting/compositing
Gray blocks          = system / idle / other

Navigation in the panel:

W / S            zoom in / out on the timeline
A / D            pan left / right
Click a bar      see Summary, Bottom-Up, Call Tree, Event Log in the drawer
Ctrl+F           search the flame chart for a function name
"Bottom-Up" tab  = which leaf functions cost the most total self-time
"Call Tree" tab  = top-down, where time goes from the entry point

Self time vs total time: Self is time in the function itself; Total includes children. A function with huge total but tiny self is just a slow caller โ€” drill down.

Finding long tasks

Any task that occupies the main thread for >50ms is a "long task" and hurts responsiveness (and INP). The Performance panel flags them with a red triangle. You can also detect them at runtime:

// Log every long task as it happens
const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task: ${entry.duration.toFixed(0)}ms`, entry.attribution);
  }
});
obs.observe({ type: "longtask", buffered: true });

Fixes once you find one:

// 1. Yield to the browser between chunks of work
async function processInChunks(items, chunk = 100) {
  for (let i = 0; i < items.length; i += chunk) {
    items.slice(i, i + chunk).forEach(doWork);
    await new Promise((r) => setTimeout(r, 0)); // or: await scheduler.yield()
  }
}

// 2. scheduler.yield() (Chrome) โ€” yields but keeps priority
async function withYield(items) {
  for (const item of items) {
    doWork(item);
    if (navigator.scheduling?.isInputPending?.()) await scheduler.yield();
  }
}

// 3. Move pure CPU work off-thread
const worker = new Worker(new URL("./crunch.js", import.meta.url), { type: "module" });
worker.postMessage(data);

Detecting layout thrashing (forced reflow)

Layout thrashing = reading a layout property right after writing one, forcing the browser to recompute layout synchronously, in a loop. In the trace it shows as purple "Layout" or "Recalculate Style" bars, often with a warning "Forced reflow is a likely performance bottleneck."

// BAD โ€” read โ†’ write โ†’ read โ†’ write forces a reflow every iteration
boxes.forEach((box) => {
  const w = box.offsetWidth;        // READ  (forces layout)
  box.style.width = w + 10 + "px";  // WRITE (invalidates layout)
});

// GOOD โ€” batch all reads, then all writes
const widths = boxes.map((box) => box.offsetWidth); // all READs
boxes.forEach((box, i) => {                          // all WRITEs
  box.style.width = widths[i] + 10 + "px";
});

Properties that force a synchronous reflow when read after a write include: offsetWidth/Height/Top/Left, clientWidth/Height, scrollWidth/Height/Top, getBoundingClientRect(), getComputedStyle(), and scrollIntoView().

// Schedule writes in a frame, reads in the next โ€” clean separation
requestAnimationFrame(() => {
  // writes
  requestAnimationFrame(() => {
    // reads (after layout has settled)
  });
});

Core Web Vitals

The three field metrics. Targets are "good" thresholds at the 75th percentile.

LCP  Largest Contentful Paint  โ€” loading       โ€” good โ‰ค 2.5s
INP  Interaction to Next Paint โ€” responsiveness โ€” good โ‰ค 200ms
CLS  Cumulative Layout Shift   โ€” visual stability โ€” good โ‰ค 0.1

Measure them in the lab with the web-vitals library:

npm install web-vitals
import { onLCP, onINP, onCLS } from "web-vitals";

onLCP((m) => console.log("LCP", m.value.toFixed(0), "ms", m.rating));
onINP((m) => console.log("INP", m.value.toFixed(0), "ms", m.rating));
onCLS((m) => console.log("CLS", m.value.toFixed(3), m.rating));

Or with raw PerformanceObserver (no dependency):

// LCP
new PerformanceObserver((l) => {
  const e = l.getEntries().at(-1);
  console.log("LCP", e.startTime.toFixed(0), "ms", e.element);
}).observe({ type: "largest-contentful-paint", buffered: true });

// CLS (sum of unexpected layout shifts)
let cls = 0;
new PerformanceObserver((l) => {
  for (const e of l.getEntries()) if (!e.hadRecentInput) cls += e.value;
  console.log("CLS", cls.toFixed(3));
}).observe({ type: "layout-shift", buffered: true });

In DevTools, the Performance panel shows LCP/CLS markers on the timeline, and the Lighthouse panel reports all three from a lab run. Use the live CrUX/CWV overlay via Command menu โ†’ "Show Core Web Vitals" for an on-page HUD.

Memory snapshots (Memory panel)

Use the Memory panel to find leaks โ€” detached DOM nodes, growing arrays, closures holding references.

Memory panel โ†’ three profiling types:
  โ€ข Heap snapshot          โ€” point-in-time map of all live JS objects
  โ€ข Allocation timeline    โ€” records allocations over time (find what allocates)
  โ€ข Allocation sampling    โ€” low-overhead sampling of allocation stacks

Leak-hunting workflow with snapshots:

1. Load page, take Heap snapshot #1 (baseline).
2. Do the suspect action N times (e.g. open/close a modal 10x).
3. Force GC: click the trash/garbage-collect icon in the Memory panel.
4. Take Heap snapshot #2.
5. In snapshot #2, switch the dropdown to "Comparison" vs snapshot #1.
6. Sort by "# Delta" / "Size Delta". Positive deltas that never free = leak.
7. Filter the class list for "Detached" to find detached DOM trees.

Confirm in code with the modern measurement API (Chrome, cross-origin-isolated pages):

if (performance.measureUserAgentSpecificMemory) {
  const result = await performance.measureUserAgentSpecificMemory();
  console.log("bytes:", result.bytes);
}

Common workflows

1. Janky scroll / dropped frames

1. Performance panel โ†’ enable Screenshots, 4x CPU throttle.
2. Record (โ—), scroll the janky region, stop (โ– ).
3. Look at the "Frames" track: red bars = dropped/partial frames.
4. Click a slow frame โ†’ Main track shows what ran during it.
5. If purple "Layout" dominates โ†’ batch reads/writes (see layout thrashing).
6. If yellow scripting dominates โ†’ open Bottom-Up, fix the top self-time function.

2. Slow page load / bad LCP

1. Performance panel โ†’ reload button (โ—โ†ป) with Slow 4G + 4x CPU.
2. Find the LCP marker on the timeline; note its element in the Summary.
3. Check the network waterfall above: is the LCP image/font late or render-blocking?
4. Fixes: preload the LCP resource, set fetchpriority="high", defer non-critical JS,
   inline critical CSS, add explicit width/height to stop the image shifting layout.
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<img src="/hero.avif" width="1200" height="630" fetchpriority="high" alt="" />

3. Laggy click / high INP

1. Record an interaction trace; perform the slow click once.
2. Find the "Interactions" track entry โ†’ it spans input to next paint.
3. Drill into the Main track inside that span: input handler + rendering.
4. If a long task blocks paint โ†’ break work up with scheduler.yield()/setTimeout(0)
   or move it to a Worker; keep the handler that updates UI synchronous and small.

4. Tab memory grows over time

1. Memory panel โ†’ Heap snapshot baseline.
2. Repeat the suspect flow 5โ€“10x, then click GC.
3. Take a 2nd snapshot โ†’ "Comparison" view, sort by Size Delta.
4. Filter "Detached" โ†’ trace a retained node's "Retainers" path to the JS that holds it
   (often an event listener never removed, or a global cache/array that only grows).
5. Fix: removeEventListener on teardown, use WeakMap/WeakRef for caches, bound the array.

Gotchas / tips

  • Profile a release-like build. Dev servers, source maps, and HMR distort timing. Use a production build, Incognito, and disable extensions.
  • Throttle on purpose. Your dev machine is fast; real users are not. 4xโ€“6x CPU and Slow/Fast 4G expose problems that "ran fine locally."
  • Self time, not total time, tells you where the CPU actually went โ€” use Bottom-Up.
  • Force GC before a comparison snapshot, or you will mistake garbage for a leak.
  • CLS counts only unexpected shifts โ€” those without hadRecentInput. Shifts within 500ms of a user action are excluded.
  • Lab โ‰  field. DevTools/Lighthouse is lab data; real INP/LCP/CLS come from CrUX field data. A green lab score can still have a poor field INP.
  • scheduler.yield() and measureUserAgentSpecificMemory() are Chromium-only and the memory API needs a cross-origin-isolated page. Feature-detect before using.
  • Record short. Multi-minute traces hang the panel; capture the smallest window that reproduces the problem.
  • Watch for forced-reflow warnings in the trace's Summary/Event Log โ€” DevTools names the exact line causing the synchronous layout.

Performance Profiling in the Browser

A copy-and-keep reference for diagnosing slow web pages with the Chrome DevTools Performance and Memory panels. It teaches you how to record a trace, read a flame chart, hunt down long tasks and layout thrashing, measure Core Web Vitals, and capture heap snapshots to find leaks.

When to use it

Reach for this when a page feels janky, scroll stutters, interactions lag, the tabโ€™s memory keeps climbing, or your Lighthouse/CrUX scores flag poor LCP, INP, or CLS. The goal is to move from a vague โ€œitโ€™s slowโ€ to a concrete frame, function, or allocation.

Whatโ€™s inside

The full cheatsheet lives in the snippet (snippets/markdown.md). It covers panel recording, flame-chart reading, long-task and layout-thrashing detection, the three Core Web Vitals, memory snapshots, plus end-to-end workflows and common gotchas.