Skip to content
Back to skills

Browser Profiling

ASecurity

Profile Chrome/JavaScript/React apps to diagnose slowness. Covers triage (load vs interaction vs scroll), Playwright-based baseline capture, React Profiler component, Chrome Performance panel interpretation, bundle analysis, memory leak detection, fix verification, and programmatic large-trace analysis via Perfetto's trace_processor (the pprof-equivalent for Chrome traces). Invoke when a browser or React app is noticeably slow and you need to find and fix the bottleneck, or when you have a do...

  • 8 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 20, 2026
developmentjavascriptpythonrustgojavashellbashsqlreactnode

Works with

  • cli
  • api
  • mcp

Security analysis

A96/100
  • mediumUses curl or wget to download content

Pro scans all 6 files and shows the line behind each finding

Scanned September 20, 2026

npx -y skills add tstapler/dotfiles --skill browser-profiling --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Browser Profiling?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Browser Profiling
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tstapler-browser-profiling/badge)](https://www.skillsdirectory.com/skills/tstapler-browser-profiling)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: browser-profiling
description: Profile Chrome/JavaScript/React apps to diagnose slowness. Covers triage (load vs interaction vs scroll), Playwright-based baseline capture, React Profiler component, Chrome Performance panel interpretation, bundle analysis, memory leak detection, fix verification, and programmatic large-trace analysis via Perfetto's trace_processor (the pprof-equivalent for Chrome traces). Invoke when a browser or React app is noticeably slow and you need to find and fix the bottleneck, or when you have a downloaded `.json`/`.json.gz` Performance-panel trace to analyze.
---

# Browser / React Performance Profiling

End-to-end workflow: triage the complaint → capture numeric baseline → isolate the layer (browser vs React vs network) → deep-dive the bottleneck → fix → verify the fix matches the baseline.

## Triage Table — Classify Before You Profile

| Symptom | Likely Layer | Primary Tool |
|---------|-------------|-------------|
| Slow initial page load | Network / bundle | Lighthouse, bundle analyzer, coverage API |
| Slow after clicking / typing | JS / React renders | Performance panel, React `<Profiler>` |
| Scrolling jank / dropped frames | Layout thrashing / paint | FPS meter, Performance panel during scroll |
| Memory grows and never recovers | Memory leak | Memory panel Heap Snapshot + Allocation Timeline |
| App slow only on low-end devices | Long tasks / large bundles | Performance panel with 6× CPU throttle |

---

## Step 1 — Capture a Numeric Baseline

Never skip this. "It feels slow" is not a measurement. Run a Playwright script before touching code that captures `page.metrics()` deltas, long-task counts, and a Chrome trace file — then compare after fixes.

See [Baseline Capture and Fix Verification](references/baseline-and-verification.md) for the full `perf-baseline.js` script and the Step 6 verification workflow.

---

## Step 2 — Add React `<Profiler>` to Slow Subtrees

Wrap the suspect component tree in a `<Profiler>`; its `onRender` callback reports `actualDuration` (this render) vs `baseDuration` (worst case without memoization).

**Key ratio**: `actualDuration / baseDuration`
- Near **1.0** → memoization is absent or not helping; every component in the subtree re-renders
- Near **0.1** → memoization works; only ~10% of the subtree re-renders on updates

In the Chrome DevTools Performance panel (React 19+), the "Scheduler" and "Components" tracks show wide bars in the **Blocking** lane (synchronous updates) or a **Cascading updates** flag (a render triggering another render).

See [React Profiler and Re-render Detection](references/react-profiler.md) for the `<Profiler>` code and the `why-did-you-render` dev tool.

---

## Step 3 — Read the Chrome Performance Panel

1. Open DevTools → Performance tab
2. Set **CPU throttling** to `4×`–`6×` to simulate mobile (reproduces problems that don't show on fast machines)
3. Click **Record** → reproduce the slow action → **Stop**
4. Read the **Main** thread row: Bottom-Up tab sorted by Self Time finds the expensive function; Call Tree tab sorted by Total Time finds the entry point that triggers the most work

See [Reading the Chrome Performance Panel](references/chrome-performance-panel.md) for the flamechart legend and key event names to search for.

---

## Step 3.5 — Analyzing a Trace Programmatically (Perfetto `trace_processor` — the pprof of Chrome traces)

A DevTools Performance recording — from the manual "Record → Save profile..." flow, or `browser.startTracing()` in Step 1 — is a JSON array of trace events (`ts`, `dur`, `ph`, `name`, `cat`, `pid`/`tid`, `args`). On a real session this file is routinely **hundreds of MB**, and it is the exact same mistake as `curl`-ing a raw Go pprof profile to grep by hand: `jq`, `python -m json.load`, or `grep` over the whole array is slow, easy to get subtly wrong (matching the wrong process/thread, double-counting nested slices), and burns a huge amount of context if the output lands in a conversation. **Load it into Perfetto's `trace_processor` and query it with SQL instead** — this is the direct equivalent of `go tool pprof -top`: one purpose-built tool does the parsing/indexing, and you get ranked, aggregated answers instead of raw event soup.

### Get the tool (one-time)

```bash
curl -LO https://get.perfetto.dev/trace_processor
chmod +x trace_processor
# First run downloads the native binary for your platform to ~/.local/share/perfetto/prebuilts (cached after).
```

`trace_processor` loads Chrome's JSON trace format directly — **including gzipped `.json.gz` downloads**, no manual `gunzip` needed. It also loads `.perfetto-trace` protobuf traces, `.heapprofile` files, and Android traces, so the same tool covers Playwright's `browser.startTracing()` output and a manually-downloaded DevTools trace.

### Query mode (non-interactive — prefer this for scripted/agent use)

```bash
./trace_processor -q <(echo "SELECT name, COUNT(*) AS cnt, SUM(dur) AS total_ns FROM slice GROUP BY name ORDER BY total_ns DESC LIMIT 20;") mytrace.json.gz
```

Or drop into the interactive SQL shell for exploration: `./trace_processor mytrace.json.gz` then type queries at the `>` prompt. Durations (`dur`, `ts`) are in **nanoseconds**.

### Canonical queries (the `-top` / `-list` equivalents)

```sql
-- Top 20 event names by total time across the whole trace (go tool pprof -top)
SELECT name, COUNT(*) AS cnt, SUM(dur) AS total_ns
FROM slice GROUP BY name ORDER BY total_ns DESC LIMIT 20;

-- Long tasks (>50ms) on a named thread, with wall-clock offset from trace start
SELECT (s.ts - (SELECT MIN(ts) FROM slice)) / 1e6 AS ms_from_start,
       s.dur / 1e6 AS dur_ms, s.name, t.name AS thread
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE s.dur > 50 * 1e6
ORDER BY s.dur DESC LIMIT 30;

-- Discover what counter tracks exist before querying one blind (mirrors
-- "list available tag keys before guessing an ASL query" for Atlas) —
-- Chrome's JS-heap counter is typically named 'JS Heap' or similar; verify
-- per-trace rather than assuming.
SELECT DISTINCT name FROM counter_track;

-- JS heap size over time once you have the exact counter_track name
SELECT c.ts, c.value
FROM counter c JOIN counter_track ct ON c.track_id = ct.id
WHERE ct.name = 'JS Heap' ORDER BY c.ts;

-- GC pauses (self time), ranked — allocation-churn signal
SELECT name, COUNT(*) AS cnt, SUM(dur) AS total_ns, AVG(dur) AS avg_ns
FROM slice WHERE name LIKE '%GC%' GROUP BY name ORDER BY total_ns DESC;
```

**Gotcha learned the hard way**: a multi-process/multi-tab trace mixes every process's counters and threads in one file — `SELECT MIN(value) FROM counter ...` across all processes can return a near-zero value that belongs to an unrelated tab/extension, not the page you're debugging. Filter to the right `pid`/`upid` first (`SELECT * FROM process;` to find it) rather than trusting an unscoped aggregate.

### When to still use the DevTools UI instead

`trace_processor` is for *ranking and aggregating* — finding which function/task/counter dominates, across a trace too large to eyeball. It does not replace:
- **The flamechart itself** for understanding *why* a specific task is slow (call stack, nesting) — drag the same trace file into DevTools Performance panel for that.
- **Heap Snapshot comparison** (Step 5) — a `.heapsnapshot` is a different format (object graph, not a timeline) and needs the Memory panel or `--inuse` uses; `trace_processor` cannot substitute for finding *which objects* are retained, only *how much* memory a counter shows over time.

### LLM-native alternative

A community MCP server (`perfetto-1` on mcpmarket.com) wraps `trace_processor_shell` and lets an LLM run PerfettoSQL queries directly against a trace without shelling out — worth adopting if this analysis becomes frequent enough to justify adding an MCP server, but the `curl`+SQL-file approach above needs no setup and is fine for occasional use.

---

## Step 4 — Diagnose by Layer

Once you know the layer (from Step 1's trace or Step 3's panel), match the symptom to a fix:

| Layer | Symptom |
|-------|---------|
| React re-render cascade | `<Profiler>` ratio near 1.0; most of the tree re-renders on every keystroke |
| Long tasks / JS-heavy interaction | Red-flagged tasks >50ms on interaction; INP > 200ms |
| Layout thrashing | "Forced reflow" warnings; `Layout` events interleaved with JS |
| Bundle size / dead code | Slow initial load; large `node_modules` in the critical bundle |

See [Diagnose by Layer](references/diagnose-by-layer.md) for the anti-pattern tables and fix code for each (memoization, `scheduler.yield()`/`startTransition`, batched reads/writes, `source-map-explorer` + `React.lazy`).

---

## Step 5 — Memory Leak Detection

**Symptom**: JS Heap grows over time in Task Manager and doesn't return to baseline after GC.

Quick check: run 10 cycles of the suspect action via Playwright, force GC, and compare `page.metrics()` heap size before/after — growth over ~5MB warrants a DevTools Heap Snapshot comparison (sort by "# New" objects). The most common React leak is a missing `useEffect` cleanup for an event listener.

See [Memory Leak Detection](references/memory-leak-detection.md) for the Playwright check script and the DevTools Heap Snapshot workflow.

---

## Step 6 — Verify the Fix

Re-run the Step 1 baseline script with a new label and compare against the original trace. Pass criteria: reduced `scriptDuration` delta, fewer/shorter long tasks, improved `<Profiler>` ratio, no new long tasks elsewhere, and (for leaks) heap growth under 1MB across 10 cycles. Full checklist in [Baseline Capture and Fix Verification](references/baseline-and-verification.md).

---

## Quick Reference

| Goal | What to use |
|------|------------|
| Establish numeric baseline | `page.metrics()` before/after + `browser.startTracing()` |
| Find expensive React subtree | `<Profiler id="X" onRender={onRender}>` |
| Find why a component re-rendered | React DevTools Components track → click component → "Why did this render?" |
| Find long tasks | Performance panel red-flagged bars / `PerformanceObserver longtask` |
| Find layout thrashing | Performance panel → "Forced reflow" warnings |
| Identify unused JS | `page.coverage.startJSCoverage()` |
| Visualize bundle composition | `npx source-map-explorer 'build/static/js/*.js'` |
| Find memory leaks | Memory panel Heap Snapshot comparison (before/after cycles) |
| Move work off main thread | `startTransition`, `scheduler.yield()`, Web Workers |
| Reproduce mobile conditions | CPU throttling 4–6× in DevTools Performance panel |

## Common Pitfalls

- **Profiling a dev build** — React dev builds are 2–3× slower than production; always profile a production or `profiling` build for realistic numbers. Use `react-dom/profiling` to keep Profiler tracks in production.
- **One `<Profiler>` wrapping the entire app** — too coarse to find the bottleneck; wrap the specific subtree you suspect
- **Optimizing without a baseline** — if you don't measure before, you can't prove the fix worked; always capture `page.metrics()` deltas
- **`useMemo`/`useCallback` without React.memo children** — these only help when a memoized child exists; adding them without a `React.memo`-wrapped consumer is pure overhead
- **Fixing inline objects in JSX without measuring first** — sometimes the cost is in computation, not re-render count; check `<Profiler>` before assuming the cause
- **Missing `key` stability after "fixing" keys** — switching from index keys to IDs is correct, but only if the IDs are stable across renders; generated UUIDs on each render are worse than index keys
- **Memory panel without forcing GC first** — always click the trash can icon (Force GC) in the Memory panel before taking a snapshot to avoid counting GC-eligible objects as leaks
- **Long task fix that only moves the work** — `startTransition` delays work; it doesn't eliminate it. If the work is truly too expensive, it needs to be broken up or moved to a Worker.

Files in this skill

  • SKILL.md11.9 KB
  • references/baseline-and-verification.md3 KB
  • references/chrome-performance-panel.md1.5 KB
  • references/diagnose-by-layer.md3.6 KB
  • references/memory-leak-detection.md1.7 KB
  • references/react-profiler.md1.9 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…