|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/performance
$cat docs/testing-—-performance-&-load-testing.md
updated Recently·14 min read·published

Testing — Performance & Load Testing

TestingAdvanced
Introduction

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 for Web Performance

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.

lighthouse-ci.js
JavaScript
1// lighthouserc.js — Lighthouse CI configuration
2module.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
65name: Lighthouse CI
66on: [pull_request]
67jobs:
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

Lighthouse CI's assertion phase is the key to preventing regressions. Set thresholds that match your current performance and tighten them gradually. A build that fails when LCP drops below 2.5s is more useful than a build that only warns about it.
k6 for Load Testing

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.

k6-load-test.js
JavaScript
1// k6 load test script — comprehensive example
2import http from "k6/http";
3import { check, sleep, group } from "k6";
4import { Rate, Trend, Counter } from "k6/metrics";
5
6// Custom metrics
7const errorRate = new Rate("errors");
8const apiLatency = new Trend("api_latency");
9const successCount = new Counter("successful_requests");
10
11// Test configuration
12export 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
32const BASE_URL = __ENV.BASE_URL || "http://localhost:3000";
33
34export 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

Start with a small load test (10-20 VUs) against your staging environment before running against production. Many applications crash under load testing due to unoptimized database queries, missing connection pools, or insufficient caching. Test in staging first, fix issues, then test in production at low traffic times.
Artillery for Load Testing

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.

artillery-load-test.yml
YAML
1# artillery-load-test.yml — Artillery test configuration
2config:
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
30scenarios:
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

Artillery's expect plugin makes load tests double as integration tests. You can verify that responses are correct under load, not just that the server stays up. This catches issues like corrupted responses or incorrect data under high concurrency.
Web Vitals in CI

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.

web-vitals-ci.ts
JavaScript
1// Web Vitals measurement in CI
2// Using the web-vitals library programmatically
3
4// vitest.config.performance.ts — custom test configuration
5import { defineConfig } from "vitest/config";
6
7export 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
16import { test, expect } from "vitest";
17import { chromium } from "playwright";
18
19test.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

Web Vitals measured in CI are synthetic — they run on your CI infrastructure, not real user devices. They are useful for catching regressions but should not replace Real-User Monitoring (RUM). Use CI vitals as a "gate" (did this PR make things worse?) and RUM as the "source of truth" (how are real users experiencing the site?).
Performance Budgets

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.

performance-budgets.js
JavaScript
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
42module.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

Set performance budgets at multiple levels: individual asset size (prevents one large file), total page weight (prevents death by a thousand cuts), and timing metrics (measures real user experience). A single budget type can be gamed — multiple types provide comprehensive protection.
Performance Regression Detection

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.

regression-detection.ts
JavaScript
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
10async 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
40import { test, expect } from "@playwright/test";
41
42const BASELINES = {
43 homepage: {
44 lcp: { mean: 1800, stdDev: 200 },
45 cls: { mean: 0.05, stdDev: 0.02 },
46 },
47};
48
49test("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

Performance metrics are inherently noisy. A single slow CI run does not indicate a regression. Use a rolling median of 3-5 runs to smooth out variance. Alert only when the rolling metric crosses a threshold, not on individual measurements.
Stress Testing vs Load Testing

While often used interchangeably, stress testing and load testing serve different purposes. Understanding the distinction helps you choose the right approach for each scenario.

AspectLoad TestingStress Testing
GoalVerify performance under expected trafficFind breaking point and failure mode
Traffic patternSimulates normal/peak trafficGradually increases until failure
Metrics focusResponse time, error rateRecovery time, failure behavior
FrequencyEvery deploymentQuarterly or after major changes
Question answered"Is the app fast enough for 1000 users?""What happens at 5000 users?"
EnvironmentStaging or production (low traffic)Staging only
stress-vs-load.js
JavaScript
1// Stress test scenario — find the breaking point
2// k6 stress-test.js
3import http from "k6/http";
4import { check, sleep } from "k6";
5
6export 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
21export 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

Never run stress tests directly against production without proper safeguards. Use a staging environment that mirrors production infrastructure. Even then, implement a kill switch that stops the test automatically if error rates exceed a danger threshold (e.g., 50% errors for 30 seconds).
Real-User Monitoring vs Synthetic

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.

AspectSyntheticRUM
Data sourceControlled test environmentsReal user browsers
CoverageSpecific pages, limited devicesAll pages, all devices
ConsistencyHigh (same conditions)Variable (network, device)
Regression detectionExcellentModerate (noisy)
RealismModeratePerfect
Setup complexityLow (script once)Medium (APM integration)
CostLowVariable (APM pricing)
rum-vs-synthetic.ts
JavaScript
1// RUM implementation with web-vitals library
2// Collect real user metrics and send to analytics
3
4// lib/web-vitals.ts
5import { onLCP, onFID, onCLS, onINP, onTTFB } from "web-vitals";
6
7type VitalMetric = {
8 name: string;
9 value: number;
10 rating: "good" | "needs-improvement" | "poor";
11};
12
13function 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
35export 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
45name: Synthetic Monitoring
46on:
47 schedule:
48 - cron: "*/15 * * * *" # Every 15 minutes
49jobs:
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

Use synthetic monitoring for regression detection (consistent, comparable results) and RUM for understanding real user experience (variable, diverse conditions). Many teams make the mistake of using only one. Synthetic without RUM misses real-world issues (slow mobile networks, old devices). RUM without synthetic lacks the consistent baseline needed for CI gates.
Best Practices
  • 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.
ci-performance.yml
JavaScript
1// Complete CI performance pipeline
2// .github/workflows/performance.yml
3name: Performance
4on: [pull_request]
5
6jobs:
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

Run load tests on a separate schedule from your main CI pipeline, not on every commit. Load tests take 5-30 minutes and consume significant infrastructure. Run them on a cron schedule (daily or per-release) and trigger them manually for PRs that touch critical API or database code.
$Blueprint — Engineering Documentation·Section ID: TEST-PERF·Revision: 1.0