Agent-readable docs index: /llms.txt. Download /docs.zip to grep all markdown files locally.

CPU Profiling, Web Vitals, and React Performance Monitoring

Playwriter gives agents full access to Chrome's built-in profilers through CDP. Capture CPU profiles, measure Web Vitals, track React component renders, and analyze network performance. All from the command line or MCP, no DevTools UI needed.

CPU profiling with CDP

Drive Chrome's V8 CPU profiler over CDP to capture .cpuprofile files, then analyze them with profano.

Start profiling

state.cdp = await getCDPSession({ page: state.page }) await state.cdp.send('Profiler.enable') await state.cdp.send('Profiler.setSamplingInterval', { interval: 1000 }) // microseconds await state.cdp.send('Profiler.start') console.log('profiling started')
The interval is in microseconds. 1000 = 1ms sample interval (default). Lower values give finer detail but larger files.

Interact with the page

Do whatever triggers the code path you want to profile. Only work between Profiler.start and Profiler.stop ends up in the profile:
await state.page.locator('button').first().click() await state.page.waitForTimeout(2000)

Stop and save

const { profile } = await state.cdp.send('Profiler.stop') await state.cdp.send('Profiler.disable') const fs = require('node:fs') fs.mkdirSync('./tmp/cpu-profiles', { recursive: true }) const path = `./tmp/cpu-profiles/browser-${Date.now()}.cpuprofile` fs.writeFileSync(path, JSON.stringify(profile)) console.log('wrote', path, '-', profile.samples.length, 'samples')

Analyze with profano

npm install -g profano # Hot leaves (default, sorted by self-time) profano ./tmp/cpu-profiles/browser-*.cpuprofile # Expensive callers (sorted by total/inclusive time) profano ./tmp/cpu-profiles/browser-*.cpuprofile --sort total -n 20
Example output:
Duration: 12.34s Samples: 11542 active / 12340 total (6.4% idle) Sort: self Self %Self Self ms Total %Total Total ms Function Location ─────── ────── ─────── ─────── ────── ──────── ────────────────────── ────────────── 3402 29.5% 3.40s 6804 58.9% 6.80s parseAsync src/parser.ts:142
Start with --sort self to find CPU-bound leaves. Switch to --sort total to find expensive callers that dominate wall time.

Web Vitals

Collect Core Web Vitals (TTFB, FCP, LCP, CLS) from any page using PerformanceObserver:
// Install observers before navigation await state.page.evaluate(() => { window.__metrics = { paints: {}, lcp: 0, cls: 0 } new PerformanceObserver(list => { for (const entry of list.getEntries()) { window.__metrics.paints[entry.name] = entry.startTime } }).observe({ type: 'paint', buffered: true }) new PerformanceObserver(list => { const entries = list.getEntries() const last = entries[entries.length - 1] if (last) window.__metrics.lcp = last.startTime }).observe({ type: 'largest-contentful-paint', buffered: true }) new PerformanceObserver(list => { for (const entry of list.getEntries()) { if (!entry.hadRecentInput) window.__metrics.cls += entry.value || 0 } }).observe({ type: 'layout-shift', buffered: true }) }) // Reload to capture fresh metrics await state.page.reload({ waitUntil: 'domcontentloaded' }) await state.page.waitForTimeout(3000) // Collect results const report = await state.page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0] return { ttfb: nav?.responseStart || 0, domContentLoaded: nav?.domContentLoadedEventEnd || 0, load: nav?.loadEventEnd || 0, fcp: window.__metrics.paints['first-contentful-paint'] || 0, lcp: window.__metrics.lcp || 0, cls: window.__metrics.cls || 0, } }) console.log(report)
MetricWhat it measures
TTFBTime to First Byte; server response time
FCPFirst Contentful Paint; first visible content
LCPLargest Contentful Paint; main content visible
CLSCumulative Layout Shift; visual stability

Long tasks and interaction latency

Detect long tasks (>50ms) and slow event handlers that block interactivity:
await state.page.evaluate(() => { window.__longTasks = [] window.__eventTimings = [] new PerformanceObserver(list => { window.__longTasks.push(...list.getEntries().map(e => ({ startTime: e.startTime, duration: e.duration }))) }).observe({ type: 'longtask', buffered: true }) new PerformanceObserver(list => { window.__eventTimings.push(...list.getEntries().map(e => ({ name: e.name, duration: e.duration, interactionId: e.interactionId || 0 }))) }).observe({ type: 'event', buffered: true, durationThreshold: 16 }) }) // Interact with the page await state.page.locator('button').first().click() // Collect results const report = await state.page.evaluate(() => ({ longTasks: window.__longTasks.filter(e => e.duration >= 50), events: window.__eventTimings.filter(e => e.interactionId !== 0), })) console.log(report)

Network analysis

Measure the heaviest transferred resources using raw CDP network events:
const cdp = await getCDPSession({ page: state.page }) await cdp.send('Network.enable') await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }) const responses = new Map() const finished = new Map() cdp.on('Network.responseReceived', event => { responses.set(event.requestId, { url: event.response.url, mimeType: event.response.mimeType, }) }) cdp.on('Network.loadingFinished', event => { finished.set(event.requestId, event.encodedDataLength) }) await state.page.reload({ waitUntil: 'domcontentloaded' }) await state.page.waitForTimeout(2000) const largest = [...responses.entries()] .map(([id, r]) => ({ url: r.url, mimeType: r.mimeType, bytes: finished.get(id) || 0 })) .sort((a, b) => b.bytes - a.bytes) .slice(0, 10) console.log(largest)

React component profiling

Track React component renders and scheduler events using React 19.2+ Performance Track entries. Requires a development or profiling build of React.

Install the observer

await state.page.evaluate(() => { window.__reactMeasures = [] const observer = new PerformanceObserver(list => { for (const entry of list.getEntries()) { if (!entry.detail?.devtools?.track) continue window.__reactMeasures.push({ name: entry.name, duration: entry.duration, startTime: entry.startTime, track: entry.detail.devtools.track, }) } }) observer.observe({ type: 'measure', buffered: true }) }) console.log('Observer installed')
React sets detail.devtools.track on every measure it emits. The filter keeps only React data and excludes unrelated measures from other libraries.

Interact with the app

Click around, navigate, toggle themes, type. Any React state change triggers component renders that get captured.

Save as .cpuprofile

Convert the captured measures to a .cpuprofile file that profano can analyze:
const measures = await state.page.evaluate(() => window.__reactMeasures) if (!measures.length) { console.log('No React measures captured'); return } const TICK = 100 const nodes = [ { id: 1, callFrame: { functionName: '(root)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 }, children: [2] }, { id: 2, callFrame: { functionName: '(idle)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 }, children: [] }, ] const nameToId = new Map() let nextId = 3 for (const m of measures) { const name = m.name.replace('\u200b', '') const key = m.track + '::' + name if (!nameToId.has(key)) { const id = nextId++ nameToId.set(key, id) nodes.push({ id, callFrame: { functionName: name, scriptId: String(id), url: m.track, lineNumber: -1, columnNumber: -1 }, children: [] }) nodes[0].children.push(id) } } const sorted = [...measures].sort((a, b) => a.startTime - b.startTime) const t0 = sorted[0].startTime const endUs = Math.round((Math.max(...sorted.map(m => m.startTime + m.duration)) - t0) * 1000) const events = sorted.map(m => ({ startUs: Math.round((m.startTime - t0) * 1000), endUs: Math.round((m.startTime + m.duration - t0) * 1000), nodeId: nameToId.get(m.track + '::' + m.name.replace('\u200b', '')), })) const samples = [] const timeDeltas = [] for (let t = 0; t < endUs; t += TICK) { let node = 2 for (const ev of events) { if (t >= ev.startUs && t < ev.endUs) node = ev.nodeId } samples.push(node) timeDeltas.push(TICK) } const fs = require('node:fs') fs.writeFileSync('./react-profile.cpuprofile', JSON.stringify({ nodes, samples, startTime: 0, endTime: endUs, timeDeltas })) console.log('Saved react-profile.cpuprofile')

Analyze

profano react-profile.cpuprofile --sort self
Example output:
Duration: 47.23s Samples: 786 active / 472317 total (99.8% idle) Sort: self Self %Self Self ms Total %Total Total ms Function Location ─────── ────── ─────── ─────── ────── ──────── ────────────────────── ────────── 258 32.8% 25.8ms 258 32.8% 25.8ms Mount Components 87 11.1% 8.7ms 87 11.1% 8.7ms EditorialPage Components 73 9.3% 7.3ms 73 9.3% 7.3ms Update Blocked Transition 62 7.9% 6.2ms 62 7.9% 6.2ms Cascading Update Blocking
The Location column shows the React track: Components for component renders, Transition/Blocking/Idle for scheduler events. Scheduler events like Cascading Update are common performance smells.

Gotchas

  • Development builds only. Production React builds don't emit performance measures. Use a profiling build (react-dom/profiling) or development mode.
  • React 19.2+ required. Earlier versions don't emit PerformanceObserver measures with devtools metadata.
  • Extension overhead. Browser extensions (React DevTools, ad blockers) show up in CPU profiles. Profile in an incognito window with extensions disabled for clean results.
  • Use getCDPSession({ page }) not context.newCDPSession(). Only the Playwriter helper works through the relay.