npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

playwright-performance-reporter

v3.14.0

Published

Measure and publish performance metrics from browser dev-tools when running playwright

Readme

playwright-performance-reporter

Release codecov version

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 per test.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

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:

Chart presenter: metrics summary cards

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

Chart presenter: JSHeapUsedSize timeline of a test

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.

Comparison presenter: JS heap of a step across runs

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-reporter

Requirements:

  • 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:

  1. workers: 1 and fullyParallel: false. All browsers would share one debugging port. To run multiple workers, see Multiple workers.
  2. A project whose use comes from a devices[...] preset. The preset sets defaultBrowserType, and the reporter uses it to detect the browser.
  3. --remote-debugging-port=<port> in launchOptions.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 one TargetMetric entry.
  • Before an observer is called on a target, the observer's plugins run 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.

onTest start 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, use onTestStep and sampling.

Observers can keep state between calls. For example, cpuProfiler holds a lock and networkActivity keeps running counters. Use a separate instance for each hook and for each sampling entry, as in example/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 test from the fixtures file, otherwise their browser has no debugging port.
  • base must be the same in the fixture and in the reporter. The ports base to base + workers - 1 must 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 test from the fixtures file. Its test.step() measures the step, so apply withPerformance after all other extend calls: a later extend creates a new test.step without 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. onTest stop 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, then npx 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 named playwright-performance-reporter in 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 request fixture of the test returned by withPerformance, its requests are recorded. page.request and request contexts created with request.newContext() are not.
  • Durations are in ms. For page requests they come from request.timing(). For the API request context they come from APIResponse.timing() in newer Playwright versions, or from the wall time around the call.
  • Percentiles over few requests are noisy, check requestCount next 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 declare capabilities: ['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", so namePattern of budgets matches it.
  • Playwright titles of API calls changed between versions, e.g. page.goto(...) in older versions and Navigate to "..." in newer ones. Check the step titles in Playwright's HTML report or trace when writing titlePattern.
  • A navigation step ends at the load event, 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:api and expect steps are frequent and short. Measure them with light observers, as heavy ones like cpuProfiler or heapDump slow the test down.
  • Invalid categories throw when the reporter is created.
  • The fixture mode only sees test.step(), so there titlePattern applies 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}.
  • statisticsPresenter leaves out retries by default, as a retry is not an independent sample. Budgets with aggregate include every execution.
  • comparisonPresenter merges 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 firefox or webkit are 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 conditions field of the test entry (TEST_CASE_PARENT). comparisonPresenter only compares runs measured under the same conditions and lists the others in excludedRuns.

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 0 count as not measured, e.g. right after a navigation.
  • device selects the curves. mobile expects slower values, so use it together with conditions throttling, if available, to compare with Lighthouse mobile.
  • comparisonPresenter treats 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 in stopMetrics[].metric
  • max / 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, min or max first condense the values of all executions of the same test or step, e.g. of repeatEach, so a single noisy execution doesn't fail the run. See Repeating for stable numbers
  • namePattern: 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, heapObjectsTracking and traceCapture measure a time range. Register them in onTest or onTestStep, not in sampling, because their onSampling does nothing. Register cpuProfiler in only one hook. The profiler is started and stopped per target, so profiling from both hooks at once interferes.
  • webVitals with attribution: true adds 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. comparisonPresenter shows the attribution of the current run below the chart of a step, and compares the numeric attribution keys like the Web Vitals.
  • networkActivity with har writes the requests of each target between onStart and onStop into a HAR 1.2 file, e.g. one per step in onTestStep. 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. waterfallPresenter renders 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.com for github.com. Set firstPartyHosts to decide by host instead. The breakdown and third party keys count since the first measurement of a target, like totalNetworkRequests, so budgets on them apply to the stop of a step.
  • networkActivity subscribes 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.
  • navigationTiming describes the latest navigation, so a step without navigation reports the previous document. Cross-origin documents without a Timing-Allow-Origin header report most phases as 0.
  • resourceTiming reads the entries of the current document, so a navigation resets them. Cross-origin resources without a Timing-Allow-Origin header hide their phases and sizes; they are reported with timingHidden: true and phases of 0. cached is a heuristic (transferSize === 0 && decodedBodySize > 0).
  • heapDump and heapObjectsTracking produce strings of many MB per target. Use them sparingly. For leaks, heapGrowth and heapDiff report the result instead of the snapshot.
  • traceCapture writes 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, onStart waits up to startTimeoutInMilliseconds. traceSizeBytes and traceDataLoss are not compared by comparisonPresenter.
  • longAnimationFrames tells which scripts blocked the main thread, while longTasks only tells that it was blocked. Its buffer works like the one of longTasks: every onStart resets it, and a new document is read with buffered: true. totalLoafBlockingDuration is 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 empty sourceURL. The API needs Chromium 123 or later, otherwise loafSupported is 0 and all values are 0. comparisonPresenter lists the top scripts of the current run below the chart of a step.
  • userTiming measures 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') becomes userTiming:measure:app:editor-ready. Its buffer works like the one of longTasks: every onStart resets 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 with namePattern. Names above maxNames per type are counted in userTimingDroppedNames. comparisonPresenter compares the userTiming: keys in milliseconds; with a custom prefix they are compared without unit.
  • Finding memory leaks: repeat the suspicious step, e.g. opening and closing a dialog, with repeatEach and a budget like {metric: 'heapGrowthBytes', max: 1024 * 1024, aggregate: 'median'} on heapGrowth. When it grows, add heapDiff to see which classes survive, e.g. Detached <div> elements still referenced by a listener, and writeSnapshots: true to 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.
  • heapGrowth and heapDiff measure a range, register them in onTestStep or onTest. Both force a garbage collection. Snapshots of heapDiff stall 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.
  • longTasks keeps one buffer per page, which every onStart resets. Each step only measures its own long tasks, and sampling reports the growing values within the current step. Using it in both onTest and onTestStep makes the steps reset the test's buffer. After a navigation, the observer is reinstalled with buffered: true to include the new document's long tasks. totalBlockingTime is 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:

  1. Run the suite once. The report status is no-baseline, and this run becomes the baseline.
  2. Keep the historyDir between runs, for example as a cached CI artifact.
  3. Runs 2 to 3 report low-confidence.
  4. From minBaselineRuns + 1 runs 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