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
| Metric | Full name | Measures | Good | Poor |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | When the largest visible element finishes rendering | < 2.5 s | > 4 s |
| INP | Interaction to Next Paint | Worst latency between user interaction and next paint | < 200 ms | > 500 ms |
| CLS | Cumulative Layout Shift | How much the layout jumps after first render (0–1 scale) | < 0.1 | > 0.25 |
| FCP | First Contentful Paint | First text/image visible | < 1.8 s | > 3 s |
| TTFB | Time To First Byte | Server 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
- LCP and CLS — Web Vitals on critical pages
- Total transfer size (JS + CSS + images) on first load
- Number of requests (often correlates with bloat)
- Time to interactive (TTI)
- Bundle size deltas per PR (vs main) — fail if > +5%
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
- Identify the LCP element via DevTools Performance panel
- Check if it's blocked by JS/CSS (look at waterfall)
- Preload critical image:
<link rel="preload" as="image" href="hero.avif"> - Inline critical CSS (above-the-fold)
- Defer non-critical JS (
async/defer) - Use modern image format (AVIF/WebP)
- Serve from CDN close to user
- HTTP/2 server push or 103 Early Hints
13.11 INP debugging recipe
- Find long tasks:
PerformanceObserver({entryTypes:['longtask']}) - Break work into chunks: yield via
scheduler.yield()orsetTimeout(0) - Move heavy work to Web Worker
- Use
requestIdleCallbackfor non-urgent work - Reduce hydration cost (Server Components, partial hydration)
- Defer 3rd-party JS (lazy-load chat, analytics)
13.12 CLS fixes (concrete)
- All images: explicit width + height OR
aspect-ratioCSS - Fonts:
font-display: optional+ preload, orsize-adjustfor fallback - Ads/embeds: reserve fixed slot via CSS
- Don't insert content above existing — slide in from edge or below
- Cookie banners: use modal overlay, not layout shift
13.13 Server-side perf budget
- p95 server response < 200 ms
- DB query p95 < 50 ms
- Cache hit rate > 90% on hot endpoints
- Memory per worker < 512 MB
- GC pause < 50 ms
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.