Skip to content

Playwright for performance testing

Playwright is the most capable browser-automation library available for scripting user journeys and capturing front-end performance metrics. This page explains how to run your existing Playwright scripts in MaxoPerf, what Playwright measures, and how to capture Core Web Vitals in a script. It also covers how to pair a small number of Playwright browsers with MaxoPerf’s scale-out protocol load for backend capacity testing.

Run your existing Playwright project in MaxoPerf

Section titled “Run your existing Playwright project in MaxoPerf”

MaxoPerf runs Playwright natively with the playwright executor. Each virtual user is a real browser, and every browser gets video, step, HAR and console capture. You don’t have to rewrite your suite:

  1. Upload your script as the entrypoint. Any file name works, and so do specs with test() blocks, specs using your own fixtures file, and library scripts that call chromium.launch() directly. You can also reference the script from a Taurus YAML with executor: playwright.

  2. Upload your playwright.config.* with it (optional). MaxoPerf wraps the config instead of replacing it. Your timeout, retries, use and reporters are kept. testMatch is pinned to your entrypoint, and one project runs per selected browser.

  3. Pick browsers. Chrome, Edge and Firefox are supported, alone or together. Your config’s projects preselect them. Virtual users are split evenly across the browsers you pick. WebKit and mobile projects are skipped with a warning.

  4. Start the run. On managed locations, MaxoPerf picks a runner size from the number of browsers each runner hosts. If the run fails, it names a cause and shows the engine’s own error text.

The full rules, with tables for config merging, project mapping, load splitting, runner size and failure causes, are in Browser tests → Playwright tests.

Browser VUs for UX, protocol VUs for scale

Section titled “Browser VUs for UX, protocol VUs for scale”

A few hundred real browsers are expensive compared with protocol virtual users. A common pattern uses Playwright as the UX measurement layer and protocol load for scale:

  1. You write a Playwright script that navigates user journeys and collects Core Web Vitals using the web-vitals JS library or window.performance APIs.
  2. You run that script on a small number of browsers, either as a MaxoPerf browser test or locally or in CI, to get accurate front-end timing measurements.
  3. You run MaxoPerf protocol load (JMeter, k6, or Taurus HTTP) at the same time or one after the other, to stress the backend at scale.
  4. You compare the two: does the Playwright-captured LCP degrade when the backend is under MaxoPerf protocol load?

This combination gives you both UX truth from the browsers and scale capacity from protocol load, without paying for hundreds of browsers.

waitUntil: 'networkidle' waits until the page has had no network connections for at least 500 ms. Pages with analytics, bot protection, payment widgets or live chat often never get there, so every iteration can time out and nothing is measured. MaxoPerf warns about each networkidle in an uploaded script. Wait for load and then for the element that proves the page is usable:

await page.goto('https://app.example.com/dashboard', { waitUntil: 'load' });
await page.getByTestId('run-list-item').first().waitFor();

Capturing Core Web Vitals in a Playwright script

Section titled “Capturing Core Web Vitals in a Playwright script”

The web-vitals JavaScript library is the standard way to measure LCP, CLS, INP, FID, and TTFB in a real browser. Inject it into your Playwright script with page.addInitScript:

import { chromium, type Page } from 'playwright';
interface WebVitalEntry {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
}
async function collectWebVitals(page: Page): Promise<WebVitalEntry[]> {
const vitals: WebVitalEntry[] = [];
// Inject web-vitals library before navigation
await page.addInitScript(() => {
// @ts-ignore — injected at runtime
window.__webVitals = [];
const script = document.createElement('script');
script.src = 'https://unpkg.com/web-vitals@3/dist/web-vitals.attribution.iife.js';
script.onload = () => {
const wv = (window as any).webVitals;
['onLCP', 'onCLS', 'onINP', 'onTTFB', 'onFCP'].forEach(fn => {
wv[fn]((metric: any) => {
(window as any).__webVitals.push({
name: metric.name,
value: metric.value,
rating: metric.rating,
});
});
});
};
document.head.appendChild(script);
});
return vitals;
}
async function measureDashboardLoad() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await collectWebVitals(page);
// Navigate to the target
const navStart = Date.now();
await page.goto('https://app.example.com/dashboard', { waitUntil: 'load' });
await page.waitForSelector('[data-testid="run-list-item"]');
const navEnd = Date.now();
// Trigger an interaction to flush INP
await page.click('[data-testid="run-list-item"]:first-child');
await page.waitForSelector('[data-testid="run-detail"]');
// Give web-vitals library time to flush final metrics
await page.waitForTimeout(500);
// Retrieve collected metrics
const webVitals = await page.evaluate(() => (window as any).__webVitals ?? []);
console.log('Navigation timing:', navEnd - navStart, 'ms');
console.log('Core Web Vitals:');
webVitals.forEach((v: WebVitalEntry) => {
console.log(` ${v.name}: ${Math.round(v.value)} ms (${v.rating})`);
});
await browser.close();
return webVitals;
}
measureDashboardLoad();

For simpler measurements, window.performance.getEntriesByType('navigation') gives you TTFB, DOM content loaded, and load event without an external library:

import { chromium } from 'playwright';
async function captureNavigationTiming(url: string) {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'load' });
const timing = await page.evaluate(() => {
const [entry] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
return {
ttfb: entry.responseStart - entry.requestStart,
domContentLoaded: entry.domContentLoadedEventEnd - entry.startTime,
loadComplete: entry.loadEventEnd - entry.startTime,
};
});
console.log('TTFB:', timing.ttfb.toFixed(0), 'ms');
console.log('DOMContentLoaded:', timing.domContentLoaded.toFixed(0), 'ms');
console.log('Load complete:', timing.loadComplete.toFixed(0), 'ms');
await browser.close();
return timing;
}

Pairing Playwright with MaxoPerf protocol load

Section titled “Pairing Playwright with MaxoPerf protocol load”

The most useful pattern is to run Playwright (for web vitals) while MaxoPerf applies backend load (for scale). It tells you whether LCP degrades under API pressure.

  1. Set up your MaxoPerf protocol load test. Create a JMeter or k6 test that targets your API endpoints at realistic concurrency (100–500 VUs). Schedule it as a MaxoPerf run.

  2. Write a Playwright web-vitals script that navigates to the pages most likely to slow down under API pressure (dashboard, search results, checkout).

  3. Run both at the same time. Start the MaxoPerf protocol load run first and wait for steady state (past the ramp-up). Then run the Playwright script from your CI or local machine.

  4. Record the Playwright output (TTFB, LCP, CLS values) alongside the MaxoPerf run timestamp.

  5. Compare. Run the Playwright script again when the MaxoPerf load has finished. Compare TTFB and LCP between “under load” and “idle” states. An LCP increase of > 20% under load is worth investigating.

Running Playwright in CI alongside MaxoPerf

Section titled “Running Playwright in CI alongside MaxoPerf”

A typical CI pipeline that gates on both backend performance and web vitals:

# .github/workflows/perf-gate.yml (illustrative)
jobs:
protocol-load:
runs-on: ubuntu-latest
steps:
- name: Trigger MaxoPerf protocol load run
run: |
RUN_ID=$(curl -s -X POST https://app.maxoperf.io/v1/runs \
-H "Authorization: Bearer $MAXOPERF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"testId": "${{ vars.LOAD_TEST_ID }}"}' | jq -r '.id')
echo "RUN_ID=$RUN_ID" >> $GITHUB_ENV
web-vitals-check:
runs-on: ubuntu-latest
needs: protocol-load
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx playwright install chromium
- name: Measure web vitals under load
run: node scripts/measure-web-vitals.js
env:
TARGET_URL: https://staging.example.com

Playwright is good at measuring web vitals on a handful of browsers. It does not replace:

  • MaxoPerf protocol load for generating the concurrent API requests that stress the backend.
  • Real-user monitoring (RUM) in production, which collects web vitals from actual users across diverse devices and networks.

Do:

  • Use Playwright for web-vitals measurement alongside MaxoPerf protocol load. Each covers what the other cannot.
  • Run Playwright scripts headless in CI to catch front-end regressions before they reach production.
  • Capture LCP, CLS, and TTFB under simulated backend load to see the user experience at scale.

Don’t:

  • Use hundreds of browser VUs for load that protocol VUs can generate far more cheaply. Keep browsers for the UX measurement.
  • Ignore LCP/TTFB degradation under load just because the backend’s p95 API latency looks acceptable.
  • Replace protocol load with browser VUs. Browsers measure UX, and protocol load measures capacity.
  • Wait for networkidle in a load script; it may never settle.