|

Lighthouse Performance Audits in Playwright

Your end-to-end suite proves the app works, but does it prove the app is fast? A page can pass every functional test and still ship a 4-second Largest Contentful Paint that quietly bleeds conversions. In this guide you’ll learn how to wire Playwright Lighthouse performance audits into your TypeScript suite so Core Web Vitals become a hard gate in CI — failing a build when performance regresses, exactly like a broken assertion would.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why run Lighthouse from Playwright?

Lighthouse is Google’s open-source auditing engine for performance, accessibility, SEO, and best practices. On its own it runs against a URL via the CLI or Chrome DevTools, but those runs are one-off and detached from your test flow. The interesting cases — a logged-in dashboard, a multi-step checkout, a page that only renders after you dismiss a cookie banner — require a real browser session with cookies, storage, and navigation already in place. That is precisely what Playwright gives you.

By bridging the two, you run a genuine Lighthouse audit against the same Chromium instance your test already drove. You reuse Playwright’s authentication state, its network mocking, and its navigation, then hand the live browser to Lighthouse over the Chrome DevTools Protocol (CDP). The result: performance scores for the exact authenticated, interacted-with state your users see — not an anonymous cold load of the marketing homepage.

ApproachAuth / interaction stateCI integrationBest for
Lighthouse CLINone (cold anonymous load)Separate stepQuick public-URL checks
Lighthouse CI (LHCI)Limited (scripted)First-class, with budgets serverTracking trends over time
Playwright + LighthouseFull (reuses Playwright session)Inside your existing suiteAuditing authenticated, post-interaction pages

Setting up the toolchain

The cleanest way to glue Playwright and Lighthouse together is the community package playwright-lighthouse, which wraps the Lighthouse Node API and accepts a Playwright page directly. You also need lighthouse itself as a peer dependency. Install all three alongside Playwright Test.

// Terminal
npm i -D @playwright/test playwright-lighthouse lighthouse
npx playwright install chromium

The single most important setup detail is the remote debugging port. Lighthouse attaches to Chrome over CDP, so the browser must be launched with a fixed --remote-debugging-port. Playwright does not expose that flag through fixtures by default, so the reliable pattern is to launch the browser yourself inside the test (or a fixture) with the port pinned. The snippet below shows the minimal working audit.

import { test } from '@playwright/test';
import { chromium } from 'playwright';
import { playAudit } from 'playwright-lighthouse';

test('homepage meets the performance budget', async () => {
  // Launch Chromium with a fixed CDP port Lighthouse can attach to.
  const browser = await chromium.launch({
    args: ['--remote-debugging-port=9222'],
  });
  const page = await browser.newPage();
  await page.goto('https://example.com');

  await playAudit({
    page,
    port: 9222,
    thresholds: {
      performance: 80,
      accessibility: 90,
      'best-practices': 90,
      seo: 90,
    },
  });

  await browser.close();
});

When any category score falls below its threshold, playAudit throws — and a thrown error fails the Playwright test, which fails the build. That single behaviour is what turns a vanity metric into an enforced contract.

Auditing an authenticated page

The real payoff of running Playwright Lighthouse performance audits is auditing pages behind a login. Because the audit runs against the same browser you control, you can sign in first — or, better, reuse a saved storageState so the audit starts from an already-authenticated context without repeating the login flow on every run.

First, save the authenticated state once (typically in global setup), then launch a context from it before auditing. The key constraint is that the persisted cookies and local storage must load before you navigate to the protected route.

import { test } from '@playwright/test';
import { chromium } from 'playwright';
import { playAudit } from 'playwright-lighthouse';

test('authenticated dashboard performance', async () => {
  const browser = await chromium.launch({
    args: ['--remote-debugging-port=9222'],
  });

  // Reuse the storageState captured during global setup.
  const context = await browser.newContext({
    storageState: 'playwright/.auth/user.json',
  });
  const page = await context.newPage();

  await page.goto('https://app.example.com/dashboard');
  await page.getByRole('heading', { name: 'Overview' }).waitFor();

  await playAudit({
    page,
    port: 9222,
    thresholds: { performance: 70, accessibility: 95 },
  });

  await browser.close();
});

You can audit any state your test can reach. Dismiss a cookie banner with page.addLocatorHandler, expand an accordion, switch a tab, or scroll to trigger lazy-loaded content — whatever your users actually do — then run the audit against that settled DOM. Lighthouse re-navigates the page for its own measurement run, but it inherits the cookies and storage of the context, so the authenticated, configured state carries over.

Reading scores and Core Web Vitals programmatically

Pass/fail thresholds are a great gate, but sometimes you want the raw numbers — to log them, push them to a dashboard, or assert on a specific metric like Largest Contentful Paint. playAudit returns the full Lighthouse result object, including the lhr (Lighthouse Report) with every category score and audit. Combine that with Playwright’s soft assertions so a single run can report on several vitals at once instead of bailing on the first failure.

import { test, expect } from '@playwright/test';
import { chromium } from 'playwright';
import { playAudit } from 'playwright-lighthouse';

test('core web vitals stay within budget', async () => {
  const browser = await chromium.launch({
    args: ['--remote-debugging-port=9222'],
  });
  const page = await browser.newPage();
  await page.goto('https://example.com/pricing');

  const { lhr } = await playAudit({
    page,
    port: 9222,
    // No thresholds here: we assert on metrics ourselves.
  });

  const audits = lhr.audits;
  const lcp = audits['largest-contentful-paint'].numericValue ?? 0;
  const cls = audits['cumulative-layout-shift'].numericValue ?? 0;
  const tbt = audits['total-blocking-time'].numericValue ?? 0;

  // Soft assertions collect every failure before the test ends.
  expect.soft(lcp, 'LCP (ms)').toBeLessThan(2500);
  expect.soft(cls, 'CLS').toBeLessThan(0.1);
  expect.soft(tbt, 'TBT (ms)').toBeLessThan(200);

  await browser.close();
});

The numericValue field holds the raw measurement: milliseconds for LCP and TBT, and a unitless ratio for CLS. Because we used expect.soft, a page that fails on two vitals reports both, giving developers the full picture in one run. The table below maps the common metrics to their Lighthouse audit IDs and the widely used “good” targets.

MetricLighthouse audit ID“Good” target
Largest Contentful Paintlargest-contentful-paint< 2500 ms
Cumulative Layout Shiftcumulative-layout-shift< 0.1
Total Blocking Timetotal-blocking-time< 200 ms
First Contentful Paintfirst-contentful-paint< 1800 ms
Speed Indexspeed-index< 3400 ms

Note that lab Total Blocking Time is the field metric Interaction to Next Paint’s closest lab proxy — Lighthouse measures TBT in the lab because INP requires real user interaction that a synthetic run cannot reproduce.

Generating and saving reports

For a failing build, a single number is rarely enough — you want the full HTML report to see which resources blocked rendering. playAudit can emit HTML, JSON, and CSV reports to a directory of your choice, which you can then publish as a CI artifact or attach to the Playwright HTML report.

import { test } from '@playwright/test';
import { chromium } from 'playwright';
import { playAudit } from 'playwright-lighthouse';

test('audit with saved HTML and JSON reports', async ({}, testInfo) => {
  const browser = await chromium.launch({
    args: ['--remote-debugging-port=9222'],
  });
  const page = await browser.newPage();
  await page.goto('https://example.com');

  await playAudit({
    page,
    port: 9222,
    thresholds: { performance: 75 },
    reports: {
      formats: { html: true, json: true },
      name: `lh-${testInfo.title.replace(/\s+/g, '-')}`,
      directory: testInfo.outputDir,
    },
  });

  // Attach the report so it shows up in the Playwright HTML report.
  await testInfo.attach('lighthouse-report', {
    path: `${testInfo.outputDir}/lh-${testInfo.title.replace(/\s+/g, '-')}.html`,
    contentType: 'text/html',
  });

  await browser.close();
});

Using testInfo.outputDir keeps each test’s artifacts isolated, and testInfo.attach surfaces the report inline in npx playwright show-report. When a build fails on performance, the reviewer clicks straight through to the offending audit instead of re-running anything locally.

A reusable fixture for clean tests

Launching the browser and pinning the port in every test is noise. Extract it into a custom fixture so each spec receives a ready-to-audit page plus the port, and the teardown closes the browser automatically. This also centralises the port number, which matters because two parallel workers must not share a debugging port.

import { test as base, type Page } from '@playwright/test';
import { chromium, type Browser } from 'playwright';

type AuditFixtures = {
  auditPage: Page;
  cdpPort: number;
};

export const test = base.extend<AuditFixtures>({
  // Derive a unique port per worker to stay parallel-safe.
  cdpPort: async ({}, use, workerInfo) => {
    await use(9222 + workerInfo.workerIndex);
  },

  auditPage: async ({ cdpPort }, use) => {
    const browser: Browser = await chromium.launch({
      args: [`--remote-debugging-port=${cdpPort}`],
    });
    const page = await browser.newPage();
    await use(page);
    await browser.close();
  },
});

export { expect } from '@playwright/test';

With that fixture in place, a test collapses to its essence — navigate and audit — and parallel safety is handled for you because each worker gets 9222 + workerIndex.

import { test } from './fixtures/audit';
import { playAudit } from 'playwright-lighthouse';

test('blog post performance budget', async ({ auditPage, cdpPort }) => {
  await auditPage.goto('https://example.com/blog/intro');
  await playAudit({
    page: auditPage,
    port: cdpPort,
    thresholds: { performance: 85, seo: 95 },
  });
});

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Practical pitfalls and tips

  • Run headless in CI, but expect variance: Lighthouse scores fluctuate with machine load. Pin a CPU-throttling config or run the audit a few times and take the median rather than gating on a single flaky number.
  • One audit per browser launch: give each playAudit call a freshly launched browser with its own port. Reusing a port across audits in the same process is a common source of “unable to connect to Chrome” errors.
  • Keep thresholds realistic: start by setting each threshold a few points below your current score, then ratchet it up. A gate that always fails gets ignored.
  • Mind the parallel ports: if you run audits across workers, derive the port from workerInfo.workerIndex as shown above, or limit audit specs to workers: 1.
  • Lighthouse re-navigates: it performs its own cold load for measurement, so client-side-only state you set without persistence may be lost — rely on cookies/storage via storageState for anything that must survive the audit navigation.

Conclusion

Functional tests answer “does it work”; performance gates answer “is it still fast.” By running Playwright Lighthouse performance audits against your real, authenticated, post-interaction pages, you fold Core Web Vitals into the same suite that already guards behaviour — complete with thresholds that fail the build, soft assertions across multiple vitals, and HTML reports attached to every run. Start small: add one audit to your most important authenticated page, set its threshold just under today’s score, and let CI hold the line from there.

FAQ

Why does Lighthouse need a remote debugging port from Playwright?

Lighthouse drives Chrome over the Chrome DevTools Protocol, and it connects by attaching to an open debugging port. When you launch Chromium with --remote-debugging-port=9222 and pass the same number to playAudit, Lighthouse attaches to the exact browser instance Playwright is already controlling, so it audits your authenticated, interacted-with session instead of a fresh anonymous one.

Can Playwright measure Interaction to Next Paint (INP) directly?

Not as a field metric. INP is computed from real user interactions over a session, which a synthetic lab run cannot reproduce. In a Lighthouse audit you instead read Total Blocking Time (total-blocking-time), the lab proxy for interactivity. For true INP you need field data from the Chrome User Experience Report or a real-user monitoring tool.

How do I keep Lighthouse audits from flaking in CI?

Scores vary with CI machine load, so avoid gating on a single run. Apply consistent CPU and network throttling through a Lighthouse config, run the audit two or three times and take the median, and set thresholds slightly below your current scores with headroom for variance. Running audit specs with workers: 1 or unique per-worker ports also prevents CDP connection clashes.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.