Performance Testing

13Performance Testing & Web Vitals

What you will master here

  • Core Web Vitals: LCP, INP, CLS — what each measures, target thresholds
  • Lighthouse via Playwright
  • Capturing Web Vitals directly in the browser
  • Performance API: timing entries, resource timing
  • Setting perf budgets in CI
  • Network throttling and CPU throttling

13.1 Core Web Vitals

MetricFull nameMeasuresGoodPoor
LCPLargest Contentful PaintWhen the largest visible element finishes rendering< 2.5 s> 4 s
INPInteraction to Next PaintWorst latency between user interaction and next paint< 200 ms> 500 ms
CLSCumulative Layout ShiftHow much the layout jumps after first render (0–1 scale)< 0.1> 0.25
FCPFirst Contentful PaintFirst text/image visible< 1.8 s> 3 s
TTFBTime To First ByteServer response start< 800 ms> 1.8 s

13.2 Capturing Web Vitals in Playwright

npm i -D web-vitals
test('home page meets web vitals', async ({ page }) => {
  await page.goto('/', { waitUntil: 'networkidle' });

  /* Inject web-vitals collector */
  const vitals = await page.evaluate(() => new Promise(resolve => {
    const out: Record<string, number> = {};
    const remaining = new Set(['LCP', 'CLS', 'FCP', 'INP', 'TTFB']);
    import('https://unpkg.com/web-vitals@4?module').then(({ onLCP, onCLS, onFCP, onINP, onTTFB }) => {
      const collect = (name: string) => (m: any) => {
        out[name] = m.value;
        remaining.delete(name);
        if (remaining.size === 0) resolve(out);
      };
      onLCP(collect('LCP'));
      onCLS(collect('CLS'));
      onFCP(collect('FCP'));
      onINP(collect('INP'));
      onTTFB(collect('TTFB'));
      /* INP needs an interaction — simulate one */
      setTimeout(() => document.body.click(), 100);
      /* fallback timeout */
      setTimeout(() => resolve(out), 5000);
    });
  }));

  console.log('vitals:', vitals);
  expect(vitals.LCP).toBeLessThan(2500);
  expect(vitals.CLS).toBeLessThan(0.1);
  expect(vitals.FCP).toBeLessThan(1800);
});

13.3 Lighthouse via Playwright

For a full Lighthouse audit (perf, a11y, SEO, best practices) use playwright-lighthouse:

npm i -D playwright-lighthouse lighthouse
import { playAudit } from 'playwright-lighthouse';
import { chromium } from 'playwright';

test('lighthouse perf budget', async () => {
  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: 90,
      accessibility: 95,
      'best-practices': 90,
      seo: 90,
    },
    reports: { formats: { html: true }, name: 'lh-report', directory: 'lh-reports' },
  });

  await browser.close();
});

13.4 Performance API — fine-grained timings

test('navigation timing budget', async ({ page }) => {
  await page.goto('/', { waitUntil: 'load' });
  const t = await page.evaluate(() => {
    const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
    return {
      ttfb: nav.responseStart - nav.requestStart,
      dom:  nav.domContentLoadedEventEnd - nav.startTime,
      load: nav.loadEventEnd - nav.startTime,
    };
  });
  expect(t.ttfb).toBeLessThan(800);
  expect(t.dom).toBeLessThan(2000);
  expect(t.load).toBeLessThan(4000);
});

test('no resource over 1 MB', async ({ page }) => {
  await page.goto('/');
  const heavy = await page.evaluate(() => performance.getEntriesByType('resource')
    .filter((r: any) => r.transferSize > 1024 * 1024)
    .map((r: any) => ({ name: r.name, size: r.transferSize })));
  expect(heavy, JSON.stringify(heavy, null, 2)).toEqual([]);
});

13.5 Network & CPU throttling

Real users aren't on a gigabit fibre. Throttle to catch perf bugs that hide on a fast dev machine.

// Via CDP session (Chromium)
test('home is usable on slow 3G', async ({ page, browserName }) => {
  test.skip(browserName !== 'chromium');
  const cdp = await page.context().newCDPSession(page);
  await cdp.send('Network.emulateNetworkConditions', {
    offline: false,
    downloadThroughput: (1.6 * 1024 * 1024) / 8,   // 1.6 Mbps
    uploadThroughput:   (750 * 1024) / 8,
    latency: 300,
  });
  await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); // 4× slower CPU

  const start = Date.now();
  await page.goto('/');
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
  expect(Date.now() - start).toBeLessThan(8000);
});

13.6 Perf budget — what to track in CI

13.7 Where Playwright stops, k6 takes over

Playwright tests a browser experience. For server-side load testing (5000 RPS, sustained load, ramp-up), use a tool built for it — k6, Gatling, Artillery. They generate concurrent HTTP traffic without paying the cost of a real browser.

// k6 — example smoke load
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = { vus: 50, duration: '30s' };

export default function () {
  const r = http.get('https://api.example.com/products');
  check(r, { 'status 200': (res) => res.status === 200 });
  sleep(1);
}

Module 15 — Interview Q&A bank

What are the Core Web Vitals?
LCP (Largest Contentful Paint) — when the biggest visible thing finishes loading; target < 2.5 s. INP (Interaction to Next Paint) — slowest UI response; target < 200 ms. CLS (Cumulative Layout Shift) — visual jank after load; target < 0.1.
How do you measure Web Vitals from Playwright?
Inject the web-vitals library via page.evaluate, subscribe to its callbacks, and resolve once the metrics arrive. Assert each metric against a budget.
How does Playwright + Lighthouse work?
Launch Chromium with a remote debugging port; playwright-lighthouse connects to that port, runs a full audit, returns category scores (performance, accessibility, SEO, best-practices). Assert each meets a threshold.
What is CLS and how do you reduce it?
Cumulative Layout Shift — how much elements move around unexpectedly after first paint. Caused by: images without explicit width/height, fonts swapping (FOIT/FOUT), ads injected late, content inserted above existing content. Fix by reserving space (intrinsic size, aspect-ratio CSS) and avoiding late insertions.
Why is INP replacing FID as a Core Web Vital?
FID only measured the first interaction. INP measures the worst across the whole session — better reflects sustained interactivity. Long-running JS handlers, layout thrash mid-session, blocking work all push INP up.
How do you throttle the network and CPU in tests?
Open a CDP session on Chromium, call Network.emulateNetworkConditions for bandwidth/latency and Emulation.setCPUThrottlingRate for CPU. Skip on Firefox/WebKit since CDP is Chromium-specific.
Why use Playwright for perf if Lighthouse exists?
Lighthouse is a one-shot audit; perf in Playwright lets you measure after specific user interactions (e.g. after clicking a heavy modal trigger) and integrates with your existing test suite, CI, and trace flow.
When should you use k6 instead?
When testing the server under load (RPS sustained, ramp-up, soak tests). Playwright spins one browser per VU — too expensive for thousands. k6 fires raw HTTP at high concurrency without a browser.
What's a good perf budget structure?
Combine an absolute budget (LCP < 2.5 s) with a delta budget (bundle size +5% vs main fails the PR). Absolute keeps you honest; delta catches regressions before they accumulate.
How do you detect a single oversized asset slipping in?
After navigation, read performance.getEntriesByType('resource'), filter to entries with transferSize > threshold, assert the list is empty. A new 5 MB image will trip it instantly.

13.8 RUM (Real User Monitoring)

Synthetic tests use one machine in one location. RUM captures actual users' diverse devices + networks. The two together = full picture.

// Client-side
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

function send(metric: any) {
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    page: location.pathname,
    deviceType: getDeviceType(),
    connection: (navigator as any).connection?.effectiveType,
  }));
}
onLCP(send); onINP(send); onCLS(send); onFCP(send); onTTFB(send);

Aggregate server-side by route + device. Alert on p75 degradation.

13.9 Resource budget enforcement (full CI)

test('resource budget', async ({ page }) => {
  await page.goto('/');
  const res = await page.evaluate(() => performance.getEntriesByType('resource').map((r: any) => ({
    name: r.name, size: r.transferSize, type: r.initiatorType
  })));
  const js  = res.filter(r => r.type === 'script').reduce((s, r) => s + r.size, 0);
  const css = res.filter(r => r.type === 'link').reduce((s, r) => s + r.size, 0);
  const img = res.filter(r => r.type === 'img').reduce((s, r) => s + r.size, 0);
  expect(js).toBeLessThan(300_000);   // 300 KB JS budget
  expect(css).toBeLessThan(50_000);   // 50 KB CSS
  expect(img).toBeLessThan(500_000);  // 500 KB images on first load
});

13.10 LCP debugging recipe

  1. Identify the LCP element via DevTools Performance panel
  2. Check if it's blocked by JS/CSS (look at waterfall)
  3. Preload critical image: <link rel="preload" as="image" href="hero.avif">
  4. Inline critical CSS (above-the-fold)
  5. Defer non-critical JS (async/defer)
  6. Use modern image format (AVIF/WebP)
  7. Serve from CDN close to user
  8. HTTP/2 server push or 103 Early Hints

13.11 INP debugging recipe

  1. Find long tasks: PerformanceObserver({entryTypes:['longtask']})
  2. Break work into chunks: yield via scheduler.yield() or setTimeout(0)
  3. Move heavy work to Web Worker
  4. Use requestIdleCallback for non-urgent work
  5. Reduce hydration cost (Server Components, partial hydration)
  6. Defer 3rd-party JS (lazy-load chat, analytics)

13.12 CLS fixes (concrete)

13.13 Server-side perf budget

Module 15 — More Perf Q&A

RUM vs synthetic — pick one?
Both. Synthetic catches regressions early in CI (controlled). RUM shows reality (diverse devices + networks). Without RUM, you ship perf that's fine on dev's MacBook and broken on $200 Android.
How to find LCP element?
DevTools Performance → run trace → look for "LCP" marker. Or web-vitals lib emits the element with the metric. Almost always: hero image or large heading.
Why INP > FID?
FID measured only first interaction. INP measures worst across whole session — better reflects sustained interactivity. Catches late JS hydration / heavy handlers users actually hit.
How to break long task?
Modern: await scheduler.yield() in the middle (Chrome). Fallback: await new Promise(r => setTimeout(r, 0)). Splits one 200 ms task into multiple ≤ 50 ms tasks.
Preload vs prefetch?
Preload: this navigation needs it (critical CSS, hero image). Prefetch: next navigation will need it. Different priorities; don't mix up.
Why is bundle size a perf metric?
JS download + parse + execute blocks LCP and adds to INP via long tasks. Each 50 KB on a slow Android adds noticeable delay. Tree-shake aggressively, code-split per route.
What's TTFB and how to improve?
Time To First Byte — server processing + network. Improve: cache hot pages at edge (CDN), reduce DB queries, faster origin (Edge runtime), HTTP/3 for handshake savings, 103 Early Hints for critical resource preload.