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 what Playwright measures, how to capture Core Web Vitals in a Playwright script, and — critically — how Playwright fits with MaxoPerf: as the web-vitals and browser-authoring layer, paired with MaxoPerf’s scale-out protocol load for backend capacity testing.

MaxoPerf runs browser tests via the Taurus selenium executor (Python WebDriver) and the wdio executor (WebdriverIO JavaScript). There is no Taurus Playwright executor — Playwright is not a Taurus-native runner.

Playwright’s role in a MaxoPerf workflow is as the browser-automation and web-vitals measurement layer:

  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 Playwright script on a small number of browsers — locally, in CI, or as a targeted check — to get accurate front-end timing measurements.
  3. You run MaxoPerf protocol load (JMeter, k6, or Taurus HTTP) in parallel or sequentially 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 (Playwright) and scale capacity (MaxoPerf), without paying the cost of running hundreds of Playwright instances.

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 using 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: 'networkidle' });
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: run Playwright (for web vitals) while MaxoPerf applies backend load (for scale). This 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 simultaneously. Start the MaxoPerf protocol load run first, wait for it to reach 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 excellent for 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.
  • Taurus selenium or wdio executor for running browser VUs inside MaxoPerf with fleet management, result storage, and the MaxoPerf dashboard.
  • 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 — they are complementary.
  • Run Playwright scripts headlessly in CI to catch front-end regressions before they reach production.
  • Capture LCP, CLS, and TTFB under simulated backend load to understand the user experience at scale.

Don’t:

  • Try to drive hundreds of Playwright instances as a load generator — this is impractical at scale.
  • Ignore LCP/TTFB degradation under load just because the backend’s p95 API latency looks acceptable.
  • Replace MaxoPerf protocol load with Playwright — Playwright measures UX; MaxoPerf measures capacity.