playwright-performance-reporter
v3.14.0
Published
Measure and publish performance metrics from browser dev-tools when running playwright
Maintainers
Readme
playwright-performance-reporter
A Playwright reporter that collects performance metrics from the browser dev tools (Chrome DevTools Protocol) while your tests run, and writes them as JSON, interactive HTML charts and run-over-run regression reports.
- No test instrumentation. The reporter collects metrics per
test()and pertest.step(). Your specs need no extra code. - Built-in observers. JS heap, CDP performance counters, CPU profile summary, Web Vitals, navigation timing (TTFB), long tasks and Total Blocking Time, long animation frames with script attribution, User Timing marks and measures, network activity with HAR export and third party breakdown, per-resource timing, Chrome traces for the DevTools Performance panel, heap snapshots, allocation sampling and memory leak detection.
- Budgets. Fail the run when a metric crosses a threshold, e.g. LCP above 2.5 s.
- Sampling. Polls metrics at a fixed interval during long tests and steps.
- Presenters. Streamed JSON, aggregated JSON, timeline JSON, a Chart.js HTML report and a comparison against previous runs.
- Extensible. Add your own observer for any CDP call, or your own presenter for any output format.
Contents
- Showcase
- Install
- Quick start
- How it works
- Configuration reference
- Chromium observers
- Presenters
- Output format
- Extending
- Browser support and limitations
- Troubleshooting / FAQ
- For AI agents and contributors
Showcase
The screenshots and snippets below come from the runnable simulation in example/ (cd example && npm run test).
chartPresenter: interactive HTML report
The report shows a summary card (average, min, max, data points) for every numeric metric of the selected test:

It also has a timeline chart for any test and metric, with one line per test execution:

comparisonPresenter: regression tracking across runs
It plots each test step and metric across all stored runs. The plot shows the per-run median, a percentile band, and the current run highlighted. A machine-readable verdict goes into a JSON report.

jsonChunkPresenter: raw data export
This is the stop measurement of a test.step('Open profile', ...). It is trimmed: there are 36 allPerformanceMetrics keys, and only a few are shown. The full schema is under Output format.
[
{
"a30a6eba6312f6b87ea5-0d3741cd336b023b92f5": {
"f4680568daecac45b5b5db3d4d7684ee": {
"name": "Open profile",
"startMetrics": [],
"stopMetrics": [
{
"metric": { "Nodes": 7951, "LayoutCount": 12, "ScriptDuration": 0.270781, "TaskDuration": 0.979239, "JSHeapUsedSize": 8311228, "JSHeapTotalSize": 14450688 },
"id": "477964E0C1FD6E5AF8D103988E967CB4",
"type": "page",
"title": "ntrotner (Nikita Trotner) · GitHub",
"url": "https://github.com/ntrotner"
},
{
"metric": { "cpuProfileDuration": 1264.311, "cpuProfileSampleCount": 1161, "cpuProfileActiveTime": 974.427, "cpuProfileScriptTime": 366.587, "cpuProfileGarbageCollectorTime": 13.046, "cpuProfileProgramTime": 594.794, "cpuProfileIdleTime": 285 },
"id": "477964E0C1FD6E5AF8D103988E967CB4",
"type": "page",
"url": "https://github.com/ntrotner"
},
{
"metric": { "largestContentfulPaint": 1380, "firstContentfulPaint": 1216, "cumulativeLayoutShift": 0.0000069, "interactionToNextPaint": 0 },
"id": "477964E0C1FD6E5AF8D103988E967CB4",
"type": "page",
"url": "https://github.com/ntrotner"
}
],
"samplingMetrics": [],
"startMeasurement": 1790507976250,
"endMeasurement": 1790507976269,
"endMeasurementOffset": 19
}
}
}
]Install
npm install --save-dev playwright-performance-reporter
# or
yarn add --dev playwright-performance-reporterRequirements:
- Node.js
>= 20 @playwright/test- Chromium. Other browsers run, but no metrics are collected for them (see limitations).
The package is ESM-only ("type": "module").
Quick start
This is a complete playwright.config.ts. Three parts are required:
workers: 1andfullyParallel: false. All browsers would share one debugging port. To run multiple workers, see Multiple workers.- A project whose
usecomes from adevices[...]preset. The preset setsdefaultBrowserType, and the reporter uses it to detect the browser. --remote-debugging-port=<port>inlaunchOptions.args. The reporter reads the port from there.
// playwright.config.ts
import {defineConfig, devices} from '@playwright/test';
import {nativeChromiumObservers, nativePresenters, type Options} from 'playwright-performance-reporter';
const performanceReporterOptions: Options = {
deleteOnFailure: false,
presenters: [
new nativePresenters.jsonChunkPresenter({outputDir: './', outputFile: 'performance.json'}),
new nativePresenters.chartPresenter({outputDir: './', outputFile: 'performance.html'}),
],
browsers: {
chromium: {
onTestStep: {
metrics: [new nativeChromiumObservers.allPerformanceMetrics()],
},
sampling: {
metrics: [{samplingTimeoutInMilliseconds: 1000, metric: new nativeChromiumObservers.usedJsHeapSize()}],
},
},
},
};
export default defineConfig({
testDir: './tests',
fullyParallel: false,
workers: 1,
reporter: [
['list'],
['playwright-performance-reporter', performanceReporterOptions],
],
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: ['--remote-debugging-port=9222'],
},
},
},
],
});Step metrics are only recorded for test.step() blocks:
// tests/example.spec.ts
import {test} from '@playwright/test';
test('open profile', async ({page}) => {
await test.step('Navigate', async () => {
await page.goto('https://github.com/ntrotner');
});
});Run npx playwright test. You get performance.json and performance.html in the working directory.
A more complete setup is in example/playwright.config.ts. It configures four presenters, sampling, CPU profiling, Web Vitals, long tasks and network activity.
How it works
onTestBegin ─► run `onTest.metrics` onStart ─► chunk with startMetrics ─┐
start sampling timers │
onStepBegin ─► run `onTestStep.metrics` onStart ─► chunk with startMetrics │
every N ms ─► run `sampling.metrics` onSampling ─► chunk with samplingMetrics├─► every presenter.write(chunk)
onStepEnd ─► run `onTestStep.metrics` onStop ─► chunk with stopMetrics │
onTestEnd ─► run `onTest.metrics` onStop ─► chunk with stopMetrics ─┘
onEnd ─► presenter.close(); and presenter.delete() if the run failed and deleteOnFailure is true- The reporter connects to the browser over CDP on the port from
launchOptions.args. It measures every CDP target: pages, iframes, workers and so on. Each target becomes oneTargetMetricentry. - Before an observer is called on a target, the observer's
pluginsrun on that target, for example to enable a CDP domain or force garbage collection. - Every measurement is written to all presenters right away as a small chunk (
ResultAccumulator). Presenters merge or stream the chunks.
Configuration reference
The options are the second element of the reporter tuple: ['playwright-performance-reporter', options]. They are typed as Options (src/types/options.ts).
Options
| Field | Type | Required | Default | Description |
|-------------------|---------------------|----------|------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| browsers | BrowserOptions | yes | none | Observers per browser and hook. See below. |
| deleteOnFailure | boolean | yes | none | If the Playwright run status is not passed, call delete() on every presenter. This removes the generated files. |
| presenters | PresenterWriter[] | no | one jsonChunkPresenter writing ./performance-report-<epochMs>.json | Output writers. Every chunk is sent to every presenter. |
| budgets | BudgetOptions | no | none (purely passive) | Performance budgets checked against the final (onStop) metrics. A violation fails the run by default. See Budgets. |
| maxListeners | number | no | Node default (10) | Sets EventEmitter.setMaxListeners() globally. Raise it if many presenters cause a MaxListenersExceededWarning. 0 removes the limit. |
| debuggingPort | {perWorker: boolean; base?: number} | no | one port from launchOptions.args | With perWorker: true, each test is measured on port base + parallelIndex of its worker. base defaults to the port in launchOptions.args, or 9222. See Multiple workers. |
| mode | 'port' \| 'fixture' | no | 'port' | fixture: tests are measured inside their worker by the withPerformance fixture, and the reporter only writes the attached measurements. See Fixture mode. |
| steps | {categories?: StepCategory[]; titlePattern?: string; nested?: boolean} | no | every test.step() | Steps measured by onTestStep and sampling. See Measuring without test.step(). |
| metadata | Record<string, string> \| () => Record<string, string> | no | detected | Information about the run, written to the metadata field of every test entry. Merged over the detected values: ci, commit, branch, ciRunId, ciRunUrl (GitHub Actions, GitLab CI, else git), platform, cpu, memory, node. |
| scoring | {device?: 'mobile' \| 'desktop'} \| false | no | {device: 'desktop'} | Lighthouse-like performanceScore of every page at the stop of a test or step. See Performance score. |
defaultOptions is exported as a ready-made starting point. It runs allPerformanceMetrics on onTest and samples usedJsHeapSize every 1000 ms. It is not applied automatically, so you have to pass it yourself: ['playwright-performance-reporter', defaultOptions].
browsers
type BrowserOptions = {
[browser in 'chromium' | 'firefox' | 'webkit']?: {
onTest?: {metrics: MetricObserver[]}; // measured at test begin and end
onTestStep?: {metrics: MetricObserver[]}; // measured at test.step() begin and end
sampling?: {
metrics: Array<{
samplingTimeoutInMilliseconds: number; // interval of setInterval()
metric: MetricObserver;
}>;
};
conditions?: ThrottlingOptions; // CPU and network throttling, chromium only
};
};| Key | When observers run | Written to |
|--------------|---------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| onTest | onStart at test begin, onStop at test end | startMetrics / stopMetrics of the TEST_CASE_PARENT entry |
| onTestStep | onStart at test.step() begin, onStop at its end. Only steps of category test.step, unless steps selects others. | startMetrics / stopMetrics of the step entry |
| sampling | onSampling every samplingTimeoutInMilliseconds, while a test or step is running | samplingMetrics of the step (or test) that is active at that moment |
Only chromium collects data today. The firefox and webkit keys are accepted, but those clients return no metrics.
onTeststart and stop measurements are often empty. At test begin, the page under test may not exist yet. At test end, it is already closed. For reliable data, useonTestStepandsampling.
Observers can keep state between calls. For example,
cpuProfilerholds a lock andnetworkActivitykeeps running counters. Use a separate instance for each hook and for each sampling entry, as inexample/playwright.config.ts.
Multiple workers
By default all tests share the browser on the one debugging port of the launch options, so only one worker can run. To measure multiple workers in parallel, give every worker's browser its own port with the perWorkerDebuggingPort fixture, and let the reporter connect to the port of the worker that runs a test:
// tests/fixtures.ts
import {test as base} from '@playwright/test';
import {perWorkerDebuggingPort} from 'playwright-performance-reporter/fixture';
// Worker n launches its browser with --remote-debugging-port=9222 + n
export const test = base.extend(perWorkerDebuggingPort({base: 9222}));// playwright.config.ts
const performanceReporterOptions: Options = {
deleteOnFailure: false,
debuggingPort: {perWorker: true, base: 9222},
browsers: {chromium: {onTestStep: {metrics: [new nativeChromiumObservers.allPerformanceMetrics()]}}},
};
export default defineConfig({
fullyParallel: true,
workers: 4,
reporter: [['playwright-performance-reporter', performanceReporterOptions]],
projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],
});- Specs have to import
testfrom the fixtures file, otherwise their browser has no debugging port. basemust be the same in the fixture and in the reporter. The portsbasetobase + workers - 1must be free.- Each worker keeps its own browser connection, active test and step, and sampling, so measurements of parallel tests don't mix.
- Workers on other machines (sharding) are not reachable. Use the fixture mode instead.
- A runnable setup is in
example/playwright.multi-worker.config.ts(cd example && npm run test:multi-worker).
Fixture mode
The fixture mode needs no debugging port at all. The withPerformance fixture measures every test inside its worker on Playwright's own CDP session (browserContext.newCDPSession(page)) and attaches the chunks to the test result. The reporter in the test runner reads them in onTestEnd and hands them to the presenters and budgets. This works with any amount of workers and with sharding, because the blob reporter keeps the attachments for merge-reports.
Keep the options in their own module, as the config and the fixture both need them:
// performance-options.ts
import {nativeChromiumObservers, nativePresenters, type Options} from 'playwright-performance-reporter';
export const performanceOptions: Options = {
deleteOnFailure: false,
mode: 'fixture',
presenters: [new nativePresenters.jsonAggregatePresenter({outputDir: './', outputFile: 'performance.json'})],
browsers: {chromium: {onTestStep: {metrics: [new nativeChromiumObservers.allPerformanceMetrics()]}}},
};// tests/fixtures.ts
import {test as base} from '@playwright/test';
import {withPerformance} from 'playwright-performance-reporter/fixture';
import {performanceOptions} from '../performance-options';
export const test = withPerformance(base, performanceOptions);// playwright.config.ts
import {performanceOptions} from './performance-options';
export default defineConfig({
fullyParallel: true,
reporter: [['playwright-performance-reporter', performanceOptions]],
projects: [{name: 'chromium', use: {...devices['Desktop Chrome']}}],
});- Specs import
testfrom the fixtures file. Itstest.step()measures the step, so applywithPerformanceafter all otherextendcalls: a laterextendcreates a newtest.stepwithout measurement. - Measurements are awaited: a step starts after its start metrics are taken, and ends after its stop metrics are taken. This removes the drift of the port mode, but adds the measurement time to the test duration.
- Every page of the test's browser context is measured, also pages opened later.
onTeststop metrics are taken before the context closes, so they contain data, unlike in port mode. - Only Chromium has CDP sessions. Tests of other browsers run unmeasured.
- Sharding: run the shards with
--reporter=blob, thennpx playwright merge-reports --config playwright.config.ts ./blob-report. The presenters run during the merge. deleteOnFailure, budgets and all presenters work as in port mode. The measurements are also visible as an attachment namedplaywright-performance-reporterin Playwright's HTML report.- A runnable setup is in
example/playwright.fixture-mode.config.ts(cd example && npm run test:fixture-mode).
API tests and request timing
requestTiming measures the requests of a test or step as Playwright sees them: the requests of every page of the context, and the requests of the request fixture. Unlike networkActivity it doesn't need a page, so it also measures API tests, but only in the fixture mode.
browsers: {
chromium: {
onTestStep: {metrics: [new nativeChromiumObservers.requestTiming({endpoints: [{name: 'search', pattern: '*/api/search*'}]})]},
},
},
budgets: {rules: [
{metric: 'requestDuration:search', max: 1000},
{metric: 'requestFailedCount', max: 0},
]},- Use the
requestfixture of the test returned bywithPerformance, its requests are recorded.page.requestand request contexts created withrequest.newContext()are not. - Durations are in ms. For page requests they come from
request.timing(). For the API request context they come fromAPIResponse.timing()in newer Playwright versions, or from the wall time around the call. - Percentiles over few requests are noisy, check
requestCountnext to them. - The fixture measures the recorded requests on a virtual target of type
playwright, so they appear as their own entry in the output. Observers declarecapabilities: ['playwright']to run on it.
Measuring without test.step()
By default only test.step() blocks are measured. steps selects other steps that Playwright reports, so existing specs can be measured without changes. For example, every navigation:
const options: Options = {
deleteOnFailure: false,
steps: {categories: ['pw:api'], titlePattern: 'Navigate to *'},
browsers: {chromium: {onTestStep: {metrics: [new nativeChromiumObservers.navigationTiming(), new nativeChromiumObservers.webVitals()]}}},
};| Field | Type | Default | Description |
|----------------|---------------------|-----------------|-------------|
| categories | Array<'test.step' \| 'pw:api' \| 'expect' \| 'fixture' \| 'hook'> | ['test.step'] | Step categories of Playwright. pw:api are Playwright API calls, e.g. Navigate to "/profile" or Click getByRole('button'). |
| titlePattern | string | '*' | Glob (* and ?) on the title of the step. |
| nested | boolean | false | Also measure selected steps within a selected step. By default a navigation inside a measured test.step() is part of that step and not measured on its own. test.step() blocks are always measured, also nested ones. |
- The step name is its title path, e.g.
Open profile > Navigate to "/profile", sonamePatternof budgets matches it. - Playwright titles of API calls changed between versions, e.g.
page.goto(...)in older versions andNavigate to "..."in newer ones. Check the step titles in Playwright's HTML report or trace when writingtitlePattern. - A navigation step ends at the
loadevent, which is before the final LCP. Web Vitals at its end show the value at that time. Sampling or a following step catches the final value. pw:apiandexpectsteps are frequent and short. Measure them with light observers, as heavy ones likecpuProfilerorheapDumpslow the test down.- Invalid categories throw when the reporter is created.
- The fixture mode only sees
test.step(), so theretitlePatternapplies and the other categories are ignored with a warning.
Repeating for stable numbers
A single execution of a step is a noisy sample. Lab tools like sitespeed.io and WebPageTest run several iterations and report the median. Use Playwright's repeatEach for the performance project, and condense the executions:
const options: Options = {
deleteOnFailure: false,
budgets: {rules: [{metric: 'largestContentfulPaint', max: 2500, aggregate: 'median'}]},
presenters: [new nativePresenters.statisticsPresenter({outputDir: './', outputFile: 'performance-statistics.json'})],
browsers: {chromium: {onTestStep: {metrics: [new nativeChromiumObservers.webVitals()]}}},
};
export default defineConfig({
repeatEach: 3,
reporter: [['playwright-performance-reporter', options]],
});- Every repeat has its own test id in Playwright, but the same name. Repeats are matched by the name of the test and the name and occurrence of the step.
- The test entry records
execution: {repeatEachIndex, retry}. statisticsPresenterleaves out retries by default, as a retry is not an independent sample. Budgets withaggregateinclude every execution.comparisonPresentermerges the repeats of a test into the entry of its first repeat, with the median across the repeats as value and the spread of the repeats as band. Runs without repeats are compared as before.
Sampling
Start and stop values alone are inaccurate for long steps. Sampling adds intermediate points:
import {nativeChromiumObservers, type Options} from 'playwright-performance-reporter';
const options: Options = {
deleteOnFailure: false,
browsers: {
chromium: {
onTestStep: {
metrics: [new nativeChromiumObservers.usedJsHeapSize()],
},
sampling: {
metrics: [
{samplingTimeoutInMilliseconds: 1000, metric: new nativeChromiumObservers.allPerformanceMetrics()},
{samplingTimeoutInMilliseconds: 1000, metric: new nativeChromiumObservers.networkActivity({includeDetailsInSampling: false, triggerGarbageCollectionOnObserve: false})},
],
},
},
},
};Conditions
CI runners and laptops are fast, so regressions on mid-range phones and mobile networks stay hidden. conditions throttles every page and frame before it's measured:
const options: Options = {
deleteOnFailure: false,
browsers: {
chromium: {
conditions: {cpuSlowdown: 4, network: 'slow-4g', disableCache: true},
onTestStep: {metrics: [new nativeChromiumObservers.allPerformanceMetrics()]},
},
},
};| Field | Type | Description |
|----------------|-----------------------------------------|-----------------------------------------------------------------------------------------------------------------|
| cpuSlowdown | number (at least 1) | CPU slowdown factor via Emulation.setCPUThrottlingRate. Lighthouse uses 4 for mobile. |
| network | preset name or NetworkProfile | Latency and throughput via Network.emulateNetworkConditions. Custom profile: {latencyInMilliseconds, downloadKilobitsPerSecond, uploadKilobitsPerSecond} (1 kbit = 1024 bit). |
| disableCache | boolean | Disable the HTTP cache via Network.setCacheDisabled to measure a cold cache. |
| Preset | Latency | Download | Upload | Source |
|-----------|----------|-----------------|--------------|-----------------------------------------|
| slow-3g | 2000 ms | 400 kbit/s | 400 kbit/s | DevTools network panel |
| fast-3g | 562.5 ms | 1440 kbit/s | 675 kbit/s | DevTools network panel |
| slow-4g | 562.5 ms | 1474.56 kbit/s | 675 kbit/s | Lighthouse mobile (applied throttling) |
| fast-4g | 165 ms | 8100 kbit/s | 1350 kbit/s | DevTools network panel |
| offline | none | none | none | Requests fail |
- Invalid conditions throw when the reporter is created. Conditions for
firefoxorwebkitare ignored with a warning. - Throttling is applied when the reporter connects to a target, which is at the first measurement of a test. The first navigation of a test can run unthrottled if it happens before that, e.g. before the first
test.step(). - Service workers and other workers are not throttled.
- The applied conditions are written to the
conditionsfield of the test entry (TEST_CASE_PARENT).comparisonPresenteronly compares runs measured under the same conditions and lists the others inexcludedRuns.
Performance score
The reporter condenses the Web Vitals and the Total Blocking Time of every page into a Lighthouse-like score from 0 to 100, with the curves and weights of Lighthouse 10 to 12. It's written as an extra stopMetrics entry of the page with performanceScore and performanceScoreMissing. It works like any other metric, e.g. in a budget:
budgets: {rules: [{metric: 'performanceScore', min: 80, namePattern: '*checkout*'}]},
scoring: {device: 'mobile'},| Metric | Observer | Weight |
|---|---|---|
| totalBlockingTime | longTasks | 30 % |
| largestContentfulPaint | webVitals | 25 % |
| cumulativeLayoutShift | webVitals | 25 % |
| firstContentfulPaint | webVitals | 10 % |
| Speed Index | not measured | 10 % |
- It's not a Lighthouse audit. It's computed from the measurements of the real, possibly logged in, page in your test. Values differ from Lighthouse, as the page state, throttling and timing differ.
- Missing metrics are listed in
performanceScoreMissing, and their weight is distributed over the measured ones. Speed Index is always missing. - A score needs LCP or FCP, and at least two metrics. Paints of
0count as not measured, e.g. right after a navigation. deviceselects the curves.mobileexpects slower values, so use it together withconditionsthrottling, if available, to compare with Lighthouse mobile.comparisonPresentertreats a lower score as regression.
Budgets
Budgets turn measured metrics into a pass/fail gate. Each rule is checked against the final (onStop) metrics
of every test and step. By default, a violation sets the status of the run to failed, so Playwright exits with a non-zero code.
const PlaywrightPerformanceReporterOptions: Options = {
...
budgets: {
onViolation: 'fail', // default, use 'warn' to only log violations
rules: [
{metric: 'largestContentfulPaint', max: 2500},
{metric: 'totalNetworkDuration', max: 3000},
{metric: 'JSHeapUsedSize', max: 120 * 1024 * 1024},
// Scope a rule with a glob (`*` and `?`) on the test or step name
{metric: 'cumulativeLayoutShift', max: 0.1, namePattern: '*checkout*'},
],
},
}metric: key of the metric as written instopMetrics[].metricmax/min: fail if any matching value is above / below the bound (at least one is required)aggregate:each(default) checks every execution.median,mean,p75,p95,minormaxfirst condense the values of all executions of the same test or step, e.g. ofrepeatEach, so a single noisy execution doesn't fail the run. See Repeating for stable numbersnamePattern: glob matched against the name of the test or step (default*). A test is named by its title path, e.g." > chromium > profile.spec.ts > Profile", a step only by its title, e.g."Open profile"
A violation is logged as Budget violation: metric "largestContentfulPaint" = 3120.5 > 2500 (ms) (test: "...").
Invalid rules throw when the reporter is created. A metric that was never measured only logs a warning, so make sure
the observer emitting it is registered in onTest or onTestStep. Sampling metrics are not evaluated.
Every retry is evaluated as well, and with deleteOnFailure: true a violation deletes the report like any other failure.
Values are compared in the native unit of the metric:
| Metric | Unit |
|---|---|
| largestContentfulPaint, firstContentfulPaint, interactionToNextPaint, totalNetworkDuration | milliseconds |
| cumulativeLayoutShift | score |
| *Size, *Bytes, e.g. JSHeapUsedSize, usedJsHeapSize, totalNetworkTransferSize | bytes |
| Other *Duration / *Time of allPerformanceMetrics, e.g. ScriptDuration, TaskDuration | seconds |
| Everything else, e.g. Nodes, totalNetworkRequests | count |
Chromium observers
The observers are available as nativeChromiumObservers.<name>. Each one is a class, so create instances with new. The sources are in src/browsers/chromium/observers/.
| Observer | Measures | Output keys in metric | Units | Hooks with data | Constructor options |
|-------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------|--------------------------------------------------------------------------------------|
| allPerformanceMetrics | All counters of CDP Performance.getMetrics | Timestamp, Documents, Frames, JSEventListeners, LayoutObjects, Nodes, Resources, LayoutCount, RecalcStyleCount, LayoutDuration, RecalcStyleDuration, ScriptDuration, V8CompileDuration, TaskDuration, TaskOtherDuration, ThreadTime, ProcessTime, JSHeapUsedSize, JSHeapTotalSize, DomContentLoaded, NavigationStart, … (whatever Chromium reports) | Counts. *Duration/*Time: seconds. JSHeap*: bytes. Timestamp, DomContentLoaded, NavigationStart: monotonic seconds | start, sampling, stop | ObserverOptions |
| usedJsHeapSize | Used JS heap (JSHeapUsedSize) | usedJsHeapSize | bytes | start, sampling, stop | ObserverOptions |
| totalJsHeapSize | Total JS heap (JSHeapTotalSize) | totalJsHeapSize | bytes | start, sampling, stop | ObserverOptions |
| cpuProfiler | V8 CPU profile between start and stop, summarized by category | cpuProfileDuration, cpuProfileSampleCount, cpuProfileActiveTime, cpuProfileScriptTime, cpuProfileGarbageCollectorTime, cpuProfileProgramTime, cpuProfileIdleTime | ms (cpuProfileSampleCount: count) | stop (start begins profiling) | {samplingIntervalInMicroseconds?: number /* 1000 */, triggerGarbageCollectionOnObserve?: boolean /* true */} |
| webVitals | Web Vitals from a persistent in-page PerformanceObserver | largestContentfulPaint, firstContentfulPaint, cumulativeLayoutShift, interactionToNextPaint. With attribution: lcpElement, lcpUrl, lcpRenderDelay, lcpSize, clsLargestShiftSource, clsLargestShiftValue, inpTarget, inpEventType, inpInputDelay, inpProcessingDuration, inpPresentationDelay | ms since the page's time origin, CLS is a unitless score. LCP and FCP are left out until the browser recorded them. Attribution: selectors and url are strings, lcpSize in pixels, clsLargestShiftValue a score, the rest ms | start, sampling, stop | {attribution?: boolean /* false */} |
| longTasks | Main-thread long tasks (> 50 ms) from an in-page PerformanceObserver, since the latest onStart | longTaskCount, totalLongTasksDuration, totalBlockingTime (sum of max(0, duration - 50)), maxLongTaskDuration | count, ms | start (always 0), sampling, stop | none |
| longAnimationFrames | Long animation frames (frames > 50 ms) and the scripts that ran in them, from an in-page PerformanceObserver, since the latest onStart | loafSupported, loafCount, totalLoafDuration, totalLoafBlockingDuration, maxLoafDuration, totalForcedStyleAndLayoutDuration, loafTopScripts (JSON string of [{sourceURL, sourceFunctionName, invoker, invokerType, duration, forcedStyleAndLayoutDuration, count}], ranked by duration), loafFrames (JSON string, with includeFrames) | count, ms, loafSupported is 1 or 0 | start (always 0), sampling, stop | {topScripts?: number /* 5 */, includeFrames?: boolean /* false */} |
| userTiming | Marks and measures of the User Timing API and element timings (elementtiming attribute) of the page since the latest onStart | userTiming:measure:<name> (duration), userTiming:mark:<name> (start time), userTiming:element:<identifier> (render time), userTimingMarkCount, userTimingMeasureCount, userTimingElementCount, userTimingDroppedNames | ms, counts | start, sampling, stop | {namePattern?: string /* '*' */, entryTypes?: Array<'mark' \| 'measure' \| 'element'> /* all */, prefix?: string /* 'userTiming' */, maxNames?: number /* 50 */} |
| navigationTiming | Navigation Timing of the current document via Runtime.evaluate | timeToFirstByte, redirectTime, dnsLookupTime, tcpConnectTime, tlsTime, requestTime (server response time), responseTime (download), domContentLoadedTime, domCompleteTime, loadEventTime, nextHopProtocol, navigationTransferSize, navigationEncodedBodySize, navigationDecodedBodySize | ms (timeToFirstByte and load milestones since the navigation start), sizes in bytes, nextHopProtocol is a string | start, sampling, stop | {includeSizes?: boolean /* true */, includeLoadMilestones?: boolean /* true */}: false drops the size/protocol keys or the load milestone keys |
| networkActivity | Requests seen via the CDP Network domain, counted from the first time this instance observed a target | totalNetworkRequests, totalNetworkTransferSize, totalNetworkDuration, networkActivities (array of {requestId, url, method, status?, transferSize?, duration?, timestamp}), totalNetworkThirdPartyRequests, totalNetworkThirdPartyTransferSize, per resource type totalNetwork<Type>Requests and totalNetwork<Type>TransferSize (Document, Script, Stylesheet, Image, Font, XHR, Fetch, Media, Other, once seen), harFile (with har, stop only) | count, bytes (encoded), ms (sum of request durations) | start, sampling, stop | {includeDetailsInSampling?: boolean /* false */, har?: {outputDir: string; filePrefix?: string /* 'network' */; urlFilter?: string \| RegExp}, firstPartyHosts?: string[], triggerGarbageCollectionOnObserve?: boolean /* ignored */}: if includeDetailsInSampling is false, networkActivities is [] while sampling |
| requestTiming | Requests of the pages and of the request fixture (API request context) since the latest onStart, as recorded by Playwright. Fixture mode only, also tests without a page | requestCount, requestFailedCount, requestDurationP50, requestDurationP95, requestDurationMax, requestTtfbP95 (if known), apiRequestCount, apiRequestDurationP95, requestDuration:<endpoint> (p95), slowestRequests (JSON string of [{url, method, status, resourceType, duration}]) | ms, counts | start (zeros), sampling, stop | {endpoints?: Array<{name: string; pattern: string}>, topSlowest?: number /* 5 */} |
| resourceTiming | Resource Timing of every subresource of the page via Runtime.evaluate | totalResources, totalTransferSize, totalEncodedBodySize, totalDecodedBodySize, totalDnsTime, totalTcpTime, totalTlsTime, totalRequestTime (time to first byte), totalResponseTime (download), totalRedirectTime, totalBlockedTime (queueing before the fetch), cacheHits, timingHiddenResources, resourceTimings (array of {name, initiatorType, nextHopProtocol, transferSize, encodedBodySize, decodedBodySize, dnsTime, tcpTime, tlsTime, requestTime, responseTime, redirectTime, blockedTime, cached, timingHidden}) | ms, sizes in bytes, counts | start, sampling, stop | {includeDetails?: boolean /* true */, bufferSize?: number /* 500 */}: includeDetails: false omits resourceTimings (recommended for sampling). bufferSize is passed to performance.setResourceTimingBufferSize() |
| heapDump | Full heap snapshot (HeapProfiler.takeHeapSnapshot) | heap | string (.heapsnapshot JSON). Very large | start, sampling, stop | ObserverOptions |
| heapProfilerSampling | Sampling heap profile between start and stop | heapProfilerSampling | string (JSON of HeapProfiler.SamplingHeapProfile) | stop (start begins sampling) | ObserverOptions |
| heapObjectsTracking | Allocation tracking between start and stop (HeapProfiler.startTrackingHeapObjects) | heapObjectsTracking | string (heap snapshot chunks). Very large | stop (start begins tracking) | ObserverOptions |
| traceCapture | Chrome trace between start and stop (Tracing.start / Tracing.end), written to a file per step and target. Load it in DevTools: Performance panel, "Load profile" | traceFile (path), traceSizeBytes, traceDataLoss (1 if Chrome dropped events because its buffer was full) | string, bytes, count | stop (start begins tracing) | {outputDir?: string /* './traces' */, filePrefix?: string /* 'trace' */, categories?: string[] /* DevTools Performance panel categories */, screenshots?: boolean /* false */, compress?: boolean /* true, gzip */, startTimeoutInMilliseconds?: number /* 5000 */, triggerGarbageCollectionOnObserve?: boolean /* false */} |
| heapGrowth | Memory kept alive between start and stop, each after a forced garbage collection | heapGrowthBytes, nodeGrowth, listenerGrowth, documentGrowth, heapGrowthPerSecond | bytes, counts | stop (start records the baseline) | none |
| heapDiff | Objects added between heap snapshots of start and stop, after garbage collection, grouped by class | heapDiffAddedObjects, heapDiffAddedBytes, heapDiffDetachedNodes, heapDiffTopClasses (JSON string of [{name, count, selfSize}]), heapDiffFile (with writeSnapshots) | counts, bytes | stop (start takes the first snapshot) | {topClasses?: number /* 20 */, writeSnapshots?: boolean /* false */, outputDir?: string /* './heap' */} |
ObserverOptions is {triggerGarbageCollectionOnObserve: boolean}. The whole argument is optional, but if you pass an object, the field is required. When it is true, the heapGarbageCollectorPlugin (HeapProfiler.collectGarbage) runs before every measurement. This makes heap values comparable. The option defaults to off, except for cpuProfiler, where it defaults to true. networkActivity accepts the field, but ignores it.
Usage:
new nativeChromiumObservers.allPerformanceMetrics({triggerGarbageCollectionOnObserve: true});
new nativeChromiumObservers.cpuProfiler({samplingIntervalInMicroseconds: 500, triggerGarbageCollectionOnObserve: false});
new nativeChromiumObservers.networkActivity({includeDetailsInSampling: true, har: {outputDir: './har'}, firstPartyHosts: ['example.com', '*.example.com']});
new nativeChromiumObservers.webVitals({attribution: true});
new nativeChromiumObservers.navigationTiming({includeSizes: false});
new nativeChromiumObservers.resourceTiming({includeDetails: false, bufferSize: 1000});
new nativeChromiumObservers.longTasks();
new nativeChromiumObservers.traceCapture({outputDir: './traces', screenshots: true});
new nativeChromiumObservers.longAnimationFrames({topScripts: 10});
new nativeChromiumObservers.userTiming({namePattern: 'app:*', entryTypes: ['measure', 'element']});
new nativeChromiumObservers.heapGrowth();
new nativeChromiumObservers.heapDiff({topClasses: 10});Notes:
cpuProfiler,heapProfilerSampling,heapObjectsTrackingandtraceCapturemeasure a time range. Register them inonTestoronTestStep, not insampling, because theironSamplingdoes nothing. RegistercpuProfilerin only one hook. The profiler is started and stopped per target, so profiling from both hooks at once interferes.webVitalswithattribution: trueadds what caused the values: the element and image url of the largest contentful paint, the element that moved in the largest layout shift, and the target, event and phases (input delay, processing, presentation delay) of the slowest interaction. Elements are described by a short selector of tag, id and up to two classes, e.g.img#hero.banner, and are empty if the element was removed or is in a cross-origin frame.comparisonPresentershows the attribution of the current run below the chart of a step, and compares the numeric attribution keys like the Web Vitals.networkActivitywithharwrites the requests of each target betweenonStartandonStopinto a HAR 1.2 file, e.g. one per step inonTestStep. The file can be imported in the Network panel of Chrome DevTools and in HAR viewers. Redirects are separate entries. Request headers are included, bodies are not.waterfallPresenterrenders the HAR files.- A request is third party if its site differs from the site of the document that started it. The site is approximated as the last two labels of the host, or three for hosts like
shop.co.uk. CDNs of the same company count as third party, e.g.githubassets.comforgithub.com. SetfirstPartyHoststo decide by host instead. The breakdown and third party keys count since the first measurement of a target, liketotalNetworkRequests, so budgets on them apply to the stop of a step. networkActivitysubscribes every new target and test. Requests that start before its first measurement of a target are not seen, e.g. the navigation right at the start of a step in port mode.navigationTimingdescribes the latest navigation, so a step without navigation reports the previous document. Cross-origin documents without aTiming-Allow-Originheader report most phases as0.resourceTimingreads the entries of the current document, so a navigation resets them. Cross-origin resources without aTiming-Allow-Originheader hide their phases and sizes; they are reported withtimingHidden: trueand phases of0.cachedis a heuristic (transferSize === 0 && decodedBodySize > 0).heapDumpandheapObjectsTrackingproduce strings of many MB per target. Use them sparingly. For leaks,heapGrowthandheapDiffreport the result instead of the snapshot.traceCapturewrites the trace into a file and only its path into the output, so presenters stay small. A trace is usually a few hundred kB to a few MB per step. Tracing slows the page down, so use it to diagnose a regression rather than in every CI run. Register it in only one hook. Chrome traces one session per target at a time; if the next step starts while the previous trace still stops,onStartwaits up tostartTimeoutInMilliseconds.traceSizeBytesandtraceDataLossare not compared bycomparisonPresenter.longAnimationFramestells which scripts blocked the main thread, whilelongTasksonly tells that it was blocked. Its buffer works like the one oflongTasks: everyonStartresets it, and a new document is read withbuffered: true.totalLoafBlockingDurationis close to the Total Blocking Time, but counts frames, which can contain several tasks. Scripts are aggregated by url, function and invoker. Inline scripts have an emptysourceURL. The API needs Chromium 123 or later, otherwiseloafSupportedis0and all values are0.comparisonPresenterlists the top scripts of the current run below the chart of a step.userTimingmeasures the milestones of your application with the same budgets and comparison as the built-in metrics, e.g.performance.measure('app:editor-ready', 'app:editor-start')becomesuserTiming:measure:app:editor-ready. Its buffer works like the one oflongTasks: everyonStartresets it. The latest entry of a name wins. Names are sanitized to letters, digits,_,.,:and-. Libraries and sites emit marks as well, often with dynamic names, so filter withnamePattern. Names abovemaxNamesper type are counted inuserTimingDroppedNames.comparisonPresentercompares theuserTiming:keys in milliseconds; with a customprefixthey are compared without unit.- Finding memory leaks: repeat the suspicious step, e.g. opening and closing a dialog, with
repeatEachand a budget like{metric: 'heapGrowthBytes', max: 1024 * 1024, aggregate: 'median'}onheapGrowth. When it grows, addheapDiffto see which classes survive, e.g.Detached <div>elements still referenced by a listener, andwriteSnapshots: trueto inspect their retainers in the Memory panel of DevTools. Compare growth across repeats rather than absolute values, as caches and lazily loaded code also grow once. heapGrowthandheapDiffmeasure a range, register them inonTestSteporonTest. Both force a garbage collection. Snapshots ofheapDiffstall the page for up to seconds on large apps, and run one after another per target.- In port mode, Playwright doesn't wait for the measurements. The reporter measures the hooks in order, so the snapshots don't overlap, but the next step already runs while the stop snapshot of the previous step is taken. Its allocations can then be counted for the previous step, and the last step of a test can be missing. The fixture mode awaits every measurement.
longTaskskeeps one buffer per page, which everyonStartresets. Each step only measures its own long tasks, andsamplingreports the growing values within the current step. Using it in bothonTestandonTestStepmakes the steps reset the test's buffer. After a navigation, the observer is reinstalled withbuffered: trueto include the new document's long tasks.totalBlockingTimeis not limited to the window between FCP and Time to Interactive, so it differs from Lighthouse's TBT.
Plugins
nativeChromiumPlugins are (client: CDP.Client) => Promise<any> functions that observers list in plugins. They run before each measurement, in order, and their errors are swallowed.
| Plugin | CDP call |
|------------------------------|------------------------------|
| performanceDomainPlugin | Performance.enable |
| heapProfilerDomainPlugin | HeapProfiler.enable |
| profilerDomainPlugin | Profiler.enable |
| networkDomainPlugin | Network.enable |
| heapGarbageCollectorPlugin | HeapProfiler.collectGarbage |
| createThrottlingPlugin(conditions) | Factory for a plugin that applies resolved conditions (Emulation.setCPUThrottlingRate, Network.emulateNetworkConditions, Network.setCacheDisabled). Used by the reporter for conditions. |
Presenters
The presenters are available as nativePresenters.<name>. The sources are in src/presenters/.
| Presenter | Output | Constructor options |
|-------------------------|--------------------------------------------------------------------------------------------|------------------------------------------------------|
| jsonChunkPresenter | JSON array of every chunk, streamed to disk (format) | {outputDir: string; outputFile: string} |
| jsonAggregatePresenter | One JSON object with all chunks merged per test and step, written on close (format) | {outputDir: string; outputFile: string; pretty?: boolean} |
| timelineDataPresenter | JSON array of numeric data points, timestamps relative to each execution (format) | {outputDir: string; outputFile: string} |
| chartPresenter | Self-contained HTML report (Chart.js) with summary cards and a timeline per test and metric. Files written by observers, i.e. metrics ending with File such as traceFile, are linked in an "Artifacts" section | {outputDir: string; outputFile: string; inlineAssets?: boolean /* true */} |
| comparisonPresenter | Stores each run in a history folder and compares the latest runs against a baseline. Writes HTML and JSON (format) | ComparisonPresenterOptions, see below |
| statisticsPresenter | JSON with count, min, max, mean, MAD and percentiles of every metric of every test and step across its executions, e.g. of repeatEach | {outputDir: string; outputFile: string; percentiles?: number[] /* [0.5, 0.75, 0.95] */; includeRetries?: boolean /* false */; pretty?: boolean} |
| markdownSummaryPresenter | Markdown table of every test and step with duration, selected metrics, the verdict of a comparisonPresenter and the budget violations. Also appended to the GitHub Actions job summary | MarkdownSummaryPresenterOptions, see below |
| waterfallPresenter | Self-contained HTML with a network waterfall and the transfer per domain of every HAR file of networkActivity({har}), linked to the HAR file | {outputDir: string; outputFile: string; maxRequests?: number /* 200 */} |
jsonChunkPresenter, timelineDataPresenter and chartPresenter open their file in the constructor. Make sure outputDir exists. jsonAggregatePresenter keeps the merged result in memory and only creates its file on close(), so outputDir must exist by the end of the run. comparisonPresenter creates its folders itself.
comparisonPresenter
new nativePresenters.comparisonPresenter({
historyDir: './performance-history', // required: one json chunk file per run is stored here
outputDir: './', // required: where the reports are written
});| Option | Type | Default | Description |
|--------------------------|--------------------------------|---------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| historyDir | string | none (required) | Folder with the JSON chunk files of all previous runs. The current run is written there as <runFilePrefix>-<ISO time>.json. |
| outputDir | string | none (required) | Folder for the reports. |
| runFilePrefix | string | 'performance-run' | File name prefix of the current run in historyDir. |
| visualReportOutputFile | string | 'performance-comparison.html' | HTML report file name. |
| inlineAssets | boolean | true | Embed Chart.js into the HTML report, so it works offline. false loads the installed version from cdn.jsdelivr.net. |
| jsonReportOutputFile | string | 'performance-comparison.json' | JSON report file name. |
| requiredMetrics | string[] | ['JSHeapUsedSize', 'TaskDuration', 'ScriptDuration', 'totalNetworkRequests', 'totalNetworkTransferSize', 'cpuProfileActiveTime'] | A run is compared only if it contains all of these metrics. The defaults need allPerformanceMetrics, networkActivity and cpuProfiler. |
| comparedMetrics | string[] | every metric of a run | Restrict the compared metrics. entityDuration (wall time of the test or step, ms) is always compared. |
| bandPercentile | number (0 to 0.5) | 0.05 | Lower band percentile of the chart. The upper band is 1 - bandPercentile. Gauges are weighted by time, counters and totals show their compared value. |
| recentRuns | number | 3 | Up to N latest runs, including the current one. Their median is compared. |
| baselineWindow | number | 20 | Number of runs before the recent runs that form the baseline. |
| relativeThreshold | number | 0.1 | Minimum relative change to report (0.1 = 10 %). |
| zScoreThreshold | number | 2 | Minimum robust z-score (change / scaled MAD of the baseline) to report. Smaller changes are noisy once minBaselineRuns exist. |
| minBaselineRuns | number | 3 | With fewer baseline runs, the report is low-confidence and only relativeThreshold applies. |
| runSortValue | (run: RunSummary) => number | run => run.startedAt | Order of runs on the chart's x-axis. |
| runLabel | (run: RunSummary) => string | run => new Date(run.startedAt).toLocaleString() | Label of runs on the chart's x-axis. |
The workflow has four steps:
- Run the suite once. The report status is
no-baseline, and this run becomes the baseline. - Keep the
historyDirbetween runs, for example as a cached CI artifact. - Runs 2 to 3 report
low-confidence. - From
minBaselineRuns + 1runs on, noise is separated from real changes.
Only runs measured under the same conditions are compared. Runs without conditions count as unthrottled.
Tests are matched across runs by the Playwright test id. Steps are matched by test id, step name and occurrence.
markdownSummaryPresenter
const comparison = new nativePresenters.comparisonPresenter({historyDir: './performance-history', outputDir: './'});
presenters: [
comparison,
new nativePresenters.markdownSummaryPresenter({outputDir: './', outputFile: 'performance-summary.md', comparison}),
],| Option | Type | Default | Description |
|---------------------|-----------------------|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
| outputDir | string | none (required) | Folder of the summary. Must exist at the end of the run. |
| outputFile | string | none (required) | File name of the summary. |
| metrics | string[] | ['largestContentfulPaint', 'totalBlockingTime', 'JSHeapUsedSize', 'totalNetworkTransferSize'] | Columns besides the duration, from the latest stopMetrics of a test or step, pages preferred. |
| comparison | ComparisonPresenter | none
