Testing — Performance & Load Testing
Performance and load testing ensure your application is fast under normal conditions and reliable under stress. Performance testing measures responsiveness and speed (load time, time-to-interactive), while load testing measures behavior under expected and peak traffic volumes.
These two disciplines are often conflated but serve different purposes. Performance testing answers "is this fast enough?", while load testing answers "does this stay fast when many users access it simultaneously?" Both are essential for production-grade applications, and both should be integrated into your CI pipeline to catch regressions before they reach users.
Lighthouse CI runs Google Lighthouse audits in your CI pipeline, collecting performance metrics, tracking them over time, and failing builds when regressions are detected. It is the standard tool for automated web performance testing.
| 1 | // lighthouserc.js — Lighthouse CI configuration |
| 2 | module.exports = { |
| 3 | ci: { |
| 4 | // Collect phase — run Lighthouse against your application |
| 5 | collect: { |
| 6 | // Number of Lighthouse runs (median is used) |
| 7 | numberOfRuns: 3, |
| 8 | // Start server before collecting |
| 9 | startServerCommand: "npm run start", |
| 10 | // URL to test |
| 11 | url: [ |
| 12 | "http://localhost:3000", |
| 13 | "http://localhost:3000/dashboard", |
| 14 | "http://localhost:3000/settings", |
| 15 | ], |
| 16 | // Settings for Lighthouse run |
| 17 | settings: { |
| 18 | preset: "desktop", // "desktop" or "perf" |
| 19 | throttlingMethod: "simulate", // "simulate", "devtools", "provided" |
| 20 | // Simulate mobile conditions |
| 21 | // formFactor: "mobile", |
| 22 | // screenEmulation: { mobile: true, width: 375, height: 812 }, |
| 23 | }, |
| 24 | }, |
| 25 | |
| 26 | // Assert phase — set performance budgets and thresholds |
| 27 | assert: { |
| 28 | preset: "lighthouse:recommended", |
| 29 | assertions: { |
| 30 | // Performance score thresholds |
| 31 | "categories:performance": ["error", { minScore: 0.9 }], |
| 32 | "categories:accessibility": ["error", { minScore: 0.9 }], |
| 33 | "categories:best-practices": ["error", { minScore: 0.9 }], |
| 34 | "categories:seo": ["error", { minScore: 0.9 }], |
| 35 | |
| 36 | // Web vital thresholds |
| 37 | "largest-contentful-paint": ["error", { maxNumericValue: 2500 }], |
| 38 | "total-blocking-time": ["error", { maxNumericValue: 200 }], |
| 39 | "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }], |
| 40 | "interactive": ["error", { maxNumericValue: 3500 }], |
| 41 | "max-potential-fid": ["error", { maxNumericValue: 100 }], |
| 42 | |
| 43 | // Resource size budgets |
| 44 | "total-byte-weight": ["error", { maxNumericValue: 1500000 }], |
| 45 | "uses-responsive-images": ["error", { minScore: 1 }], |
| 46 | "offscreen-images": ["error", { minScore: 1 }], |
| 47 | "unused-javascript": ["warn", { maxNumericValue: 100000 }], |
| 48 | "unused-css-rules": ["warn", { maxNumericValue: 50000 }], |
| 49 | }, |
| 50 | }, |
| 51 | |
| 52 | // Upload phase — store results for trend tracking |
| 53 | upload: { |
| 54 | target: "temporary-public-storage", |
| 55 | // Or use LHCI server for private storage: |
| 56 | // target: "lhci", |
| 57 | // serverBaseUrl: "https://your-lhci-server.com", |
| 58 | // token: process.env.LHCI_TOKEN, |
| 59 | }, |
| 60 | }, |
| 61 | }; |
| 62 | |
| 63 | // GitHub Actions workflow for Lighthouse CI |
| 64 | // .github/workflows/lighthouse.yml |
| 65 | name: Lighthouse CI |
| 66 | on: [pull_request] |
| 67 | jobs: |
| 68 | lhci: |
| 69 | runs-on: ubuntu-latest |
| 70 | steps: |
| 71 | - uses: actions/checkout@v4 |
| 72 | - uses: actions/setup-node@v4 |
| 73 | - run: npm ci |
| 74 | - run: npm run build |
| 75 | - name: Run Lighthouse CI |
| 76 | run: npx lhci autorun |
| 77 | env: |
| 78 | LHCI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
info
k6 is a modern load testing tool written in Go with a JavaScript API for defining test scenarios. It is developer-friendly, scriptable, and integrates well with CI pipelines. Unlike older tools (JMeter, Gatling), k6 uses JavaScript for test scripts, making it accessible to frontend and full-stack developers.
| 1 | // k6 load test script — comprehensive example |
| 2 | import http from "k6/http"; |
| 3 | import { check, sleep, group } from "k6"; |
| 4 | import { Rate, Trend, Counter } from "k6/metrics"; |
| 5 | |
| 6 | // Custom metrics |
| 7 | const errorRate = new Rate("errors"); |
| 8 | const apiLatency = new Trend("api_latency"); |
| 9 | const successCount = new Counter("successful_requests"); |
| 10 | |
| 11 | // Test configuration |
| 12 | export const options = { |
| 13 | // Stages: ramp up, stay, ramp down |
| 14 | stages: [ |
| 15 | { duration: "2m", target: 50 }, // Ramp up to 50 users |
| 16 | { duration: "5m", target: 50 }, // Stay at 50 users |
| 17 | { duration: "2m", target: 100 }, // Ramp up to 100 users |
| 18 | { duration: "5m", target: 100 }, // Stay at 100 users |
| 19 | { duration: "2m", target: 0 }, // Ramp down to 0 |
| 20 | ], |
| 21 | |
| 22 | // Thresholds — fail the test if exceeded |
| 23 | thresholds: { |
| 24 | http_req_duration: ["p(95)<2000"], // 95% of requests under 2s |
| 25 | http_req_failed: ["rate<0.01"], // Less than 1% failure rate |
| 26 | errors: ["rate<0.05"], // Custom error rate under 5% |
| 27 | api_latency: ["avg<1000"], // Average API latency under 1s |
| 28 | }, |
| 29 | }; |
| 30 | |
| 31 | // Base URL from environment variable |
| 32 | const BASE_URL = __ENV.BASE_URL || "http://localhost:3000"; |
| 33 | |
| 34 | export default function () { |
| 35 | group("User browsing flow", () => { |
| 36 | // 1. Visit homepage |
| 37 | const homeResp = http.get(BASE_URL); |
| 38 | check(homeResp, { |
| 39 | "homepage status is 200": (r) => r.status === 200, |
| 40 | "homepage loads fast": (r) => r.timings.duration < 2000, |
| 41 | }); |
| 42 | apiLatency.add(homeResp.timings.duration); |
| 43 | successCount.add(1); |
| 44 | sleep(1); |
| 45 | |
| 46 | // 2. Browse products |
| 47 | const productsResp = http.get(`${BASE_URL}/api/products`); |
| 48 | check(productsResp, { |
| 49 | "products API status is 200": (r) => r.status === 200, |
| 50 | "products returned": (r) => JSON.parse(r.body).length > 0, |
| 51 | }); |
| 52 | apiLatency.add(productsResp.timings.duration); |
| 53 | errorRate.add(productsResp.status !== 200); |
| 54 | sleep(2); |
| 55 | |
| 56 | // 3. View product detail |
| 57 | const detailResp = http.get(`${BASE_URL}/api/products/1`); |
| 58 | check(detailResp, { |
| 59 | "product detail status is 200": (r) => r.status === 200, |
| 60 | "product has name": (r) => JSON.parse(r.body).name !== "", |
| 61 | }); |
| 62 | apiLatency.add(detailResp.timings.duration); |
| 63 | sleep(1); |
| 64 | |
| 65 | // 4. Submit search |
| 66 | const searchResp = http.get(`${BASE_URL}/api/search?q=widget`); |
| 67 | check(searchResp, { |
| 68 | "search status is 200": (r) => r.status === 200, |
| 69 | }); |
| 70 | apiLatency.add(searchResp.timings.duration); |
| 71 | sleep(3); |
| 72 | }); |
| 73 | } |
| 74 | |
| 75 | // Run: k6 run --vus 10 --duration 30s load-test.js |
| 76 | // Run with environment: k6 run -e BASE_URL=https://staging.example.com load-test.js |
| 77 | // Run in cloud: k6 cloud load-test.js |
info
Artillery is an alternative load testing tool focused on simplicity and YAML-based configuration. It supports HTTP, WebSocket, Socket.io, and gRPC protocols. Artillery's declarative YAML format makes it easier to get started than k6 for simple scenarios.
| 1 | # artillery-load-test.yml — Artillery test configuration |
| 2 | config: |
| 3 | target: "http://localhost:3000" |
| 4 | phases: |
| 5 | - duration: 60 |
| 6 | arrivalRate: 5 # Start: 5 new users per second |
| 7 | rampTo: 50 # Ramp to 50 users per second |
| 8 | name: "Warm up phase" |
| 9 | - duration: 120 |
| 10 | arrivalRate: 50 # Stay at 50 users per second |
| 11 | name: "Sustained load" |
| 12 | - duration: 60 |
| 13 | arrivalRate: 50 |
| 14 | rampTo: 100 # Ramp to 100 users per second |
| 15 | name: "Peak load" |
| 16 | http: |
| 17 | timeout: 30 |
| 18 | maxSockets: 50 # Max concurrent connections |
| 19 | plugins: |
| 20 | expect: {} # Enable response validation |
| 21 | metrics-by-endpoint: {} # Per-endpoint metrics |
| 22 | variables: |
| 23 | productId: |
| 24 | - 1 |
| 25 | - 2 |
| 26 | - 3 |
| 27 | - 4 |
| 28 | - 5 |
| 29 | |
| 30 | scenarios: |
| 31 | - name: "Browse and purchase flow" |
| 32 | flow: |
| 33 | # Visit homepage |
| 34 | - get: |
| 35 | url: "/" |
| 36 | expect: |
| 37 | statusCode: 200 |
| 38 | contentType: html |
| 39 | |
| 40 | # Browse products |
| 41 | - get: |
| 42 | url: "/api/products" |
| 43 | expect: |
| 44 | statusCode: 200 |
| 45 | contentType: json |
| 46 | hasProperty: "data" |
| 47 | |
| 48 | # View product detail |
| 49 | - get: |
| 50 | url: "/api/products/{{ productId }}" |
| 51 | expect: |
| 52 | statusCode: 200 |
| 53 | contentType: json |
| 54 | |
| 55 | # Add to cart |
| 56 | - post: |
| 57 | url: "/api/cart" |
| 58 | json: |
| 59 | productId: "{{ productId }}" |
| 60 | quantity: 1 |
| 61 | expect: |
| 62 | statusCode: 201 |
| 63 | |
| 64 | # Think time |
| 65 | - think: 2 |
| 66 | |
| 67 | # Submit order |
| 68 | - post: |
| 69 | url: "/api/orders" |
| 70 | json: |
| 71 | items: |
| 72 | - productId: "{{ productId }}" |
| 73 | quantity: 1 |
| 74 | expect: |
| 75 | statusCode: 201 |
| 76 | contentType: json |
| 77 | hasProperty: "orderId" |
| 78 | |
| 79 | # Run: npx artillery run artillery-load-test.yml |
| 80 | # Report: npx artillery run --output report.json artillery-load-test.yml |
| 81 | # HTML report: npx artillery report report.json |
info
Core Web Vitals are Google's set of metrics that measure user experience: Largest Contentful Paint (LCP), First Input Delay (FID), and Cumulative Layout Shift (CLS). Tracking these in CI prevents performance regressions before they reach production and affect your search rankings.
| 1 | // Web Vitals measurement in CI |
| 2 | // Using the web-vitals library programmatically |
| 3 | |
| 4 | // vitest.config.performance.ts — custom test configuration |
| 5 | import { defineConfig } from "vitest/config"; |
| 6 | |
| 7 | export default defineConfig({ |
| 8 | test: { |
| 9 | include: ["**/*.perf.test.ts"], |
| 10 | testTimeout: 60000, // Performance tests take longer |
| 11 | globalSetup: "./test/perf/global-setup.ts", |
| 12 | }, |
| 13 | }); |
| 14 | |
| 15 | // test/perf/web-vitals.test.ts |
| 16 | import { test, expect } from "vitest"; |
| 17 | import { chromium } from "playwright"; |
| 18 | |
| 19 | test.describe("Core Web Vitals", () => { |
| 20 | let browser; |
| 21 | let page; |
| 22 | |
| 23 | test.beforeAll(async () => { |
| 24 | browser = await chromium.launch(); |
| 25 | }); |
| 26 | |
| 27 | test.afterAll(async () => { |
| 28 | await browser.close(); |
| 29 | }); |
| 30 | |
| 31 | test("LCP should be under 2.5s on homepage", async () => { |
| 32 | page = await browser.newPage(); |
| 33 | |
| 34 | // Start performance tracing |
| 35 | await page.coverage.startJSCoverage(); |
| 36 | |
| 37 | // Navigate and wait for LCP |
| 38 | await page.goto("http://localhost:3000", { |
| 39 | waitUntil: "networkidle", |
| 40 | }); |
| 41 | |
| 42 | // Measure LCP using Performance API |
| 43 | const lcp = await page.evaluate(() => { |
| 44 | return new Promise((resolve) => { |
| 45 | new PerformanceObserver((list) => { |
| 46 | const entries = list.getEntries(); |
| 47 | resolve(entries[entries.length - 1].startTime); |
| 48 | }).observe({ type: "largest-contentful-paint", buffered: true }); |
| 49 | }); |
| 50 | }); |
| 51 | |
| 52 | expect(lcp).toBeLessThan(2500); |
| 53 | }); |
| 54 | |
| 55 | test("CLS should be under 0.1 on homepage", async () => { |
| 56 | const cls = await page.evaluate(() => { |
| 57 | return new Promise((resolve) => { |
| 58 | let cumulativeScore = 0; |
| 59 | new PerformanceObserver((list) => { |
| 60 | for (const entry of list.getEntries()) { |
| 61 | if (!entry.hadRecentInput) { |
| 62 | cumulativeScore += entry.value; |
| 63 | } |
| 64 | } |
| 65 | resolve(cumulativeScore); |
| 66 | }).observe({ type: "layout-shift", buffered: true }); |
| 67 | }); |
| 68 | }); |
| 69 | |
| 70 | expect(cls).toBeLessThan(0.1); |
| 71 | }); |
| 72 | |
| 73 | test("TBT should be under 200ms on homepage", async () => { |
| 74 | // Total Blocking Time: sum of long task durations |
| 75 | const tbt = await page.evaluate(() => { |
| 76 | return new Promise((resolve) => { |
| 77 | let totalBlockingTime = 0; |
| 78 | new PerformanceObserver((list) => { |
| 79 | for (const entry of list.getEntries()) { |
| 80 | totalBlockingTime += entry.duration - 50; |
| 81 | } |
| 82 | resolve(totalBlockingTime); |
| 83 | }).observe({ type: "longtask", buffered: true }); |
| 84 | }); |
| 85 | }); |
| 86 | |
| 87 | expect(tbt).toBeLessThan(200); |
| 88 | }); |
| 89 | }); |
| 90 | |
| 91 | // Run: npx vitest run --config vitest.config.performance.ts |
warning
A performance budget is a set of limits on metrics that affect user experience. When a budget is exceeded, the build fails or a warning is issued. Budgets prevent gradual performance degradation by making it visible in every deployment.
| 1 | // Performance budgets — multiple measurement tools |
| 2 | |
| 3 | // 1. Lighthouse CI budget (lighthouserc.js) |
| 4 | // budgets.json |
| 5 | { |
| 6 | "budgets": [ |
| 7 | { |
| 8 | "path": "/*", |
| 9 | "resourceSizes": [ |
| 10 | { "resourceType": "total", "budget": 1000000 }, // 1 MB total |
| 11 | { "resourceType": "script", "budget": 350000 }, // 350 KB JS |
| 12 | { "resourceType": "stylesheet", "budget": 50000 }, // 50 KB CSS |
| 13 | { "resourceType": "image", "budget": 500000 }, // 500 KB images |
| 14 | { "resourceType": "font", "budget": 100000 }, // 100 KB fonts |
| 15 | { "resourceType": "third-party", "budget": 250000 } // 250 KB third-party |
| 16 | ], |
| 17 | "resourceCounts": [ |
| 18 | { "resourceType": "script", "budget": 10 }, // Max 10 JS files |
| 19 | { "resourceType": "stylesheet", "budget": 3 }, // Max 3 CSS files |
| 20 | { "resourceType": "image", "budget": 15 }, // Max 15 images |
| 21 | { "resourceType": "font", "budget": 3 }, // Max 3 fonts |
| 22 | { "resourceType": "total", "budget": 30 } // Max 30 requests |
| 23 | ], |
| 24 | "timings": [ |
| 25 | { "metric": "interactive", "budget": 3000 }, // 3s TTI |
| 26 | { "metric": "first-contentful-paint", "budget": 1500 }, // 1.5s FCP |
| 27 | { "metric": "largest-contentful-paint", "budget": 2500 }, // 2.5s LCP |
| 28 | { "metric": "cumulative-layout-shift", "budget": 0.1 } // 0.1 CLS |
| 29 | ] |
| 30 | }, |
| 31 | { |
| 32 | "path": "/dashboard", |
| 33 | "timings": [ |
| 34 | { "metric": "interactive", "budget": 5000 } // Dashboard gets 5s |
| 35 | ] |
| 36 | } |
| 37 | ] |
| 38 | } |
| 39 | |
| 40 | // 2. Webpack performance budgets |
| 41 | // webpack.config.js |
| 42 | module.exports = { |
| 43 | performance: { |
| 44 | maxEntrypointSize: 250000, // 244 KB per entry point |
| 45 | maxAssetSize: 100000, // 98 KB per asset |
| 46 | hints: "error", // Fail on budget exceeded |
| 47 | assetFilter: (filename) => !filename.endsWith(".map"), |
| 48 | }, |
| 49 | }; |
| 50 | |
| 51 | // 3. size-limit — package.json performance budgets |
| 52 | // package.json |
| 53 | { |
| 54 | "size-limit": [ |
| 55 | { |
| 56 | "path": "dist/index.js", |
| 57 | "limit": "100 KB", |
| 58 | "running": false |
| 59 | }, |
| 60 | { |
| 61 | "path": "dist/index.js", |
| 62 | "limit": "50 KB", |
| 63 | "import": "{ Button, Input, Modal }" |
| 64 | } |
| 65 | ] |
| 66 | } |
| 67 | |
| 68 | // Run: npx size-limit |
info
Performance regression detection compares current performance metrics against a baseline to identify degradation. This is more sophisticated than simple budgets because it accounts for natural variance and focuses on statistically significant changes.
| 1 | // Performance regression detection strategies |
| 2 | |
| 3 | // Strategy 1: Compare against previous commit baseline |
| 4 | // Using Lighthouse CI comparison mode |
| 5 | // npx lhci autorun — automatically compares against main branch |
| 6 | // Shows: "LCP changed from 1.8s to 2.3s (+28%)" |
| 7 | |
| 8 | // Strategy 2: Statistical comparison with multiple runs |
| 9 | // Run Lighthouse N times, compare distribution |
| 10 | async function detectRegression(current: number[], baseline: number[]) { |
| 11 | const currentMean = mean(current); |
| 12 | const baselineMean = mean(baseline); |
| 13 | |
| 14 | // Check if difference exceeds noise threshold (10%) |
| 15 | const change = (currentMean - baselineMean) / baselineMean; |
| 16 | |
| 17 | if (Math.abs(change) > 0.1) { |
| 18 | // More sophisticated: Mann-Whitney U test |
| 19 | // or check if confidence intervals overlap |
| 20 | return { |
| 21 | regressed: change > 0.1, |
| 22 | improved: change < -0.1, |
| 23 | percentChange: change * 100, |
| 24 | severity: Math.abs(change) > 0.25 ? "critical" : "warning", |
| 25 | }; |
| 26 | } |
| 27 | |
| 28 | return { regressed: false, improved: false, percentChange: 0 }; |
| 29 | } |
| 30 | |
| 31 | // Strategy 3: Bundle size regression detection |
| 32 | // Compare current bundle with baseline using bundlesize or size-limit |
| 33 | // CI step: |
| 34 | // 1. Build the app |
| 35 | // 2. Run size-limit to check against budgets |
| 36 | // 3. Store results for trend tracking |
| 37 | |
| 38 | // Strategy 4: Custom Playwright performance tests |
| 39 | // with statistical baselines |
| 40 | import { test, expect } from "@playwright/test"; |
| 41 | |
| 42 | const BASELINES = { |
| 43 | homepage: { |
| 44 | lcp: { mean: 1800, stdDev: 200 }, |
| 45 | cls: { mean: 0.05, stdDev: 0.02 }, |
| 46 | }, |
| 47 | }; |
| 48 | |
| 49 | test("homepage LCP not significantly worse than baseline", async ({ page }) => { |
| 50 | const metrics = await measureWebVitals(page); |
| 51 | |
| 52 | // Check if current LCP is within 3 standard deviations |
| 53 | const baseline = BASELINES.homepage.lcp; |
| 54 | const zScore = (metrics.lcp - baseline.mean) / baseline.stdDev; |
| 55 | |
| 56 | expect(zScore).toBeLessThan(3); // 3 sigma threshold |
| 57 | }); |
| 58 | |
| 59 | // Strategy 5: Synthetic monitoring with CI trend charts |
| 60 | // Store metrics in a time-series database |
| 61 | // Visualize with Grafana or similar |
| 62 | // Alert on: 3 consecutive measurements above threshold |
| 63 | // 7-day rolling average degrading |
info
While often used interchangeably, stress testing and load testing serve different purposes. Understanding the distinction helps you choose the right approach for each scenario.
| Aspect | Load Testing | Stress Testing |
|---|---|---|
| Goal | Verify performance under expected traffic | Find breaking point and failure mode |
| Traffic pattern | Simulates normal/peak traffic | Gradually increases until failure |
| Metrics focus | Response time, error rate | Recovery time, failure behavior |
| Frequency | Every deployment | Quarterly or after major changes |
| Question answered | "Is the app fast enough for 1000 users?" | "What happens at 5000 users?" |
| Environment | Staging or production (low traffic) | Staging only |
| 1 | // Stress test scenario — find the breaking point |
| 2 | // k6 stress-test.js |
| 3 | import http from "k6/http"; |
| 4 | import { check, sleep } from "k6"; |
| 5 | |
| 6 | export const options = { |
| 7 | // Gradually increase load until failure |
| 8 | stages: [ |
| 9 | { duration: "2m", target: 100 }, // Normal load |
| 10 | { duration: "5m", target: 500 }, // High load |
| 11 | { duration: "5m", target: 1000 }, // Extreme load |
| 12 | { duration: "2m", target: 2000 }, // Breaking point search |
| 13 | { duration: "2m", target: 0 }, // Cool down |
| 14 | ], |
| 15 | thresholds: { |
| 16 | http_req_duration: ["p(95)<5000"], // Lenient threshold for stress |
| 17 | http_req_failed: ["rate<0.1"], // 10% failure rate acceptable |
| 18 | }, |
| 19 | }; |
| 20 | |
| 21 | export default function () { |
| 22 | const res = http.get("http://staging.example.com/api/heavy-report"); |
| 23 | check(res, { |
| 24 | "status is 200 or 503": (r) => |
| 25 | r.status === 200 || r.status === 503, |
| 26 | }); |
| 27 | sleep(1); |
| 28 | } |
| 29 | |
| 30 | // Expectations for stress test: |
| 31 | // 1. Identify at what concurrency level errors spike |
| 32 | // 2. Identify at what concurrency level response time degrades |
| 33 | // 3. Verify graceful degradation (503s, not crashes) |
| 34 | // 4. Verify recovery after load subsides |
| 35 | // 5. Check for memory leaks (monitor process memory) |
| 36 | |
| 37 | // Key stress test findings: |
| 38 | // - 100 users: 99th percentile 200ms, 0% errors |
| 39 | // - 500 users: 99th percentile 800ms, 0.1% errors |
| 40 | // - 1000 users: 99th percentile 2.5s, 2% errors |
| 41 | // - 2000 users: 99th percentile 8s, 15% errors ← BREAKING POINT |
| 42 | // - Recovery: After load drops, response times return to baseline |
| 43 | // - Conclusion: Application breaks between 1000-2000 concurrent users |
| 44 | // - Action: Scale database connection pool, add read replicas |
warning
Real-User Monitoring (RUM) collects performance data from actual users visiting your site, while synthetic monitoring runs controlled tests from simulated environments. Both are necessary for a complete performance strategy.
| Aspect | Synthetic | RUM |
|---|---|---|
| Data source | Controlled test environments | Real user browsers |
| Coverage | Specific pages, limited devices | All pages, all devices |
| Consistency | High (same conditions) | Variable (network, device) |
| Regression detection | Excellent | Moderate (noisy) |
| Realism | Moderate | Perfect |
| Setup complexity | Low (script once) | Medium (APM integration) |
| Cost | Low | Variable (APM pricing) |
| 1 | // RUM implementation with web-vitals library |
| 2 | // Collect real user metrics and send to analytics |
| 3 | |
| 4 | // lib/web-vitals.ts |
| 5 | import { onLCP, onFID, onCLS, onINP, onTTFB } from "web-vitals"; |
| 6 | |
| 7 | type VitalMetric = { |
| 8 | name: string; |
| 9 | value: number; |
| 10 | rating: "good" | "needs-improvement" | "poor"; |
| 11 | }; |
| 12 | |
| 13 | function sendToAnalytics(metric: VitalMetric) { |
| 14 | // Send to your analytics provider |
| 15 | const body = JSON.stringify({ |
| 16 | name: metric.name, |
| 17 | value: metric.value, |
| 18 | rating: metric.rating, |
| 19 | url: window.location.pathname, |
| 20 | userAgent: navigator.userAgent, |
| 21 | connection: (navigator as any).connection?.effectiveType, |
| 22 | timestamp: Date.now(), |
| 23 | }); |
| 24 | |
| 25 | // Use sendBeacon for reliable delivery (doesn't block page unload) |
| 26 | navigator.sendBeacon("/api/vitals", body); |
| 27 | |
| 28 | // Also log in development |
| 29 | if (process.env.NODE_ENV === "development") { |
| 30 | console.log(`[Web Vital] ${metric.name}: ${metric.value} (${metric.rating})`); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | // Initialize RUM collection |
| 35 | export function initWebVitals() { |
| 36 | onLCP(sendToAnalytics); |
| 37 | onFID(sendToAnalytics); |
| 38 | onCLS(sendToAnalytics); |
| 39 | onINP(sendToAnalytics); // Interaction to Next Paint (new in 2024) |
| 40 | onTTFB(sendToAnalytics); // Time to First Byte |
| 41 | } |
| 42 | |
| 43 | // Synthetic monitoring — replay user flows |
| 44 | // .github/workflows/synthetic.yml |
| 45 | name: Synthetic Monitoring |
| 46 | on: |
| 47 | schedule: |
| 48 | - cron: "*/15 * * * *" # Every 15 minutes |
| 49 | jobs: |
| 50 | monitor: |
| 51 | runs-on: ubuntu-latest |
| 52 | steps: |
| 53 | - uses: actions/checkout@v4 |
| 54 | - run: npm ci |
| 55 | - name: Run synthetic checks |
| 56 | run: npx playwright test --config=playwright.perf.config.ts |
| 57 | - name: Report to dashboard |
| 58 | if: failure() |
| 59 | run: | |
| 60 | echo "Performance regression detected!" |
| 61 | echo "Notify #performance channel in Slack" |
info
- Start performance testing early — Performance testing is not something you add after launch. Integrate Lighthouse CI and bundle size checks from the first commit.
- Test on realistic hardware — Lighthouse CI defaults to desktop with fast network. Always add a mobile profile — real users on 4G with mid-range phones will have very different experiences.
- Automate load testing before major releases — Run a full load test suite before every major feature release. Document the breaking point and ensure it is above your projected traffic.
- Monitor both synthetic and RUM — Use synthetic tests for CI gates and RUM for production dashboards. They measure different things and both are necessary.
- Set alerts on trends, not thresholds — A single slow page load is noise. A 7-day rolling average trending upward is a problem that needs investigation.
- Document performance budgets and review them quarterly — Budgets should tighten over time as your application optimizes. Review and adjust them every quarter during planning.
| 1 | // Complete CI performance pipeline |
| 2 | // .github/workflows/performance.yml |
| 3 | name: Performance |
| 4 | on: [pull_request] |
| 5 | |
| 6 | jobs: |
| 7 | bundle-size: |
| 8 | runs-on: ubuntu-latest |
| 9 | steps: |
| 10 | - uses: actions/checkout@v4 |
| 11 | - run: npm ci |
| 12 | - run: npm run build |
| 13 | - name: Check bundle size |
| 14 | run: npx size-limit |
| 15 | |
| 16 | lighthouse: |
| 17 | runs-on: ubuntu-latest |
| 18 | steps: |
| 19 | - uses: actions/checkout@v4 |
| 20 | - run: npm ci |
| 21 | - run: npm run build |
| 22 | - name: Lighthouse CI |
| 23 | run: npx lhci autorun |
| 24 | env: |
| 25 | LHCI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 26 | |
| 27 | web-vitals: |
| 28 | runs-on: ubuntu-latest |
| 29 | steps: |
| 30 | - uses: actions/checkout@v4 |
| 31 | - run: npm ci |
| 32 | - run: npm run build |
| 33 | - name: Run web vital tests |
| 34 | run: npx vitest run --config vitest.config.performance.ts |
| 35 | |
| 36 | load-test: |
| 37 | runs-on: ubuntu-latest |
| 38 | if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'load-test') |
| 39 | steps: |
| 40 | - uses: actions/checkout@v4 |
| 41 | - run: npm ci |
| 42 | - name: Deploy to staging |
| 43 | run: ./scripts/deploy-staging.sh |
| 44 | - name: Run k6 load test |
| 45 | run: k6 run k6-tests/smoke-test.js |
| 46 | env: |
| 47 | BASE_URL: https://staging.example.com |
info