.cpuprofile files, then analyze them with profano.12345state.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')
interval is in microseconds. 1000 = 1ms sample interval (default). Lower values give finer detail but larger files.Profiler.start and Profiler.stop ends up in the profile:12await state.page.locator('button').first().click() await state.page.waitForTimeout(2000)
1234567const { 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')
1234567npm 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
1234567Duration: 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
--sort self to find CPU-bound leaves. Switch to --sort total to find expensive callers that dominate wall time.12345678910111213141516171819202122232425262728293031323334353637383940// 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)
| Metric | What it measures |
| TTFB | Time to First Byte; server response time |
| FCP | First Contentful Paint; first visible content |
| LCP | Largest Contentful Paint; main content visible |
| CLS | Cumulative Layout Shift; visual stability |
1234567891011121314151617181920212223242526272829await 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)
123456789101112131415161718192021222324252627const 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)
12345678910111213141516await 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')
detail.devtools.track on every measure it emits. The filter keeps only React data and excludes unrelated measures from other libraries..cpuprofile file that profano can analyze:12345678910111213141516171819202122232425262728293031323334353637383940414243444546const 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')
1profano react-profile.cpuprofile --sort self
12345678910Duration: 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
Components for component renders, Transition/Blocking/Idle for scheduler events. Scheduler events like Cascading Update are common performance smells.react-dom/profiling) or development mode.PerformanceObserver measures with devtools metadata.getCDPSession({ page }) not context.newCDPSession(). Only the Playwriter helper works through the relay.