Performance Testing
Learn how to test and optimize web application performance using Lighthouse CI, Web Vitals, k6, and Artillery. Ensure your apps are fast, responsive, and scalable.
Why Performance Testing?
Performance testing ensures your application remains fast and responsive under various conditions. Poor performance leads to:
- Lost revenue: Slow sites convert less
- Poor user experience: Frustrated users leave
- Lower SEO rankings: Google penalizes slow sites
- Higher bounce rates: Users abandon slow pages
53% of mobile users abandon sites that take longer than 3 seconds to load. Performance is a feature, not an afterthought.
Types of Performance Testing
| Type | Purpose | Example Tools |
|---|---|---|
| Load Testing | Test under expected load | k6, Artillery |
| Stress Testing | Test beyond capacity | k6, JMeter |
| Spike Testing | Test sudden traffic spikes | k6, Gatling |
| Endurance Testing | Test over extended time | k6, LoadRunner |
| Browser Performance | Test frontend metrics | Lighthouse, WebPageTest |
Lighthouse CI
Lighthouse is an automated tool for improving web page quality, measuring performance, accessibility, SEO, and more.
Installation
npm install --save-dev @lhci/cliConfiguration
// lighthouserc.js
module.exports = {
ci: {
collect: {
// Static site
staticDistDir: './dist',
// Or URLs to test
url: [
'http://localhost:3000/',
'http://localhost:3000/about',
'http://localhost:3000/products'
],
// Number of runs (median is used)
numberOfRuns: 3,
// Lighthouse settings
settings: {
preset: 'desktop',
// or 'mobile'
onlyCategories: ['performance', 'accessibility', 'best-practices', 'seo'],
skipAudits: ['uses-http2']
}
},
assert: {
preset: 'lighthouse:recommended',
assertions: {
// Performance thresholds
'categories:performance': ['error', { minScore: 0.9 }],
'categories:accessibility': ['error', { minScore: 0.9 }],
'categories:best-practices': ['error', { minScore: 0.9 }],
'categories:seo': ['error', { minScore: 0.9 }],
// Specific metrics
'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 300 }],
'speed-index': ['error', { maxNumericValue: 3000 }],
// Resource sizes
'resource-summary:script:size': ['error', { maxNumericValue: 200000 }],
'resource-summary:image:size': ['error', { maxNumericValue: 500000 }],
'resource-summary:stylesheet:size': ['error', { maxNumericValue: 50000 }],
// Warnings
'uses-long-cache-ttl': ['warn', { minScore: 0.7 }],
'offscreen-images': 'off' // Disable specific audits
}
},
upload: {
target: 'temporary-public-storage'
// or 'lhci' for Lighthouse CI server
}
}
};Running Lighthouse CI
# Collect performance data
lhci collect
# Assert against thresholds
lhci assert
# Upload results
lhci upload
# All in one
lhci autorun// package.json
{
"scripts": {
"lighthouse": "lhci autorun",
"lighthouse:collect": "lhci collect",
"lighthouse:assert": "lhci assert"
}
}CI/CD Integration
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push, pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}Advanced Configuration
// lighthouserc.js
module.exports = {
ci: {
collect: {
startServerCommand: 'npm start',
startServerReadyPattern: 'ready on',
startServerReadyTimeout: 30000,
// Puppeteer options
puppeteerScript: './lighthouse-setup.js',
puppeteerLaunchOptions: {
args: ['--no-sandbox']
}
}
}
};// lighthouse-setup.js
module.exports = async (browser, context) => {
// Login before testing
const page = await browser.newPage();
await page.goto('http://localhost:3000/login');
await page.type('#email', 'test@example.com');
await page.type('#password', 'password');
await page.click('button[type="submit"]');
await page.waitForNavigation();
};Run Lighthouse on every PR to catch performance regressions before they reach production.
Web Vitals Testing
Core Web Vitals are Google’s metrics for measuring user experience on the web.
Core Web Vitals
| Metric | What it Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading performance | ≤ 2.5s | 2.5s - 4s | > 4s |
| FID (First Input Delay) | Interactivity | ≤ 100ms | 100ms - 300ms | > 300ms |
| CLS (Cumulative Layout Shift) | Visual stability | ≤ 0.1 | 0.1 - 0.25 | > 0.25 |
Measuring in Tests
// tests/web-vitals.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Web Vitals', () => {
test('should have good Core Web Vitals', async ({ page }) => {
await page.goto('http://localhost:3000');
// Measure Web Vitals using web-vitals library
const vitals = await page.evaluate(() => {
return new Promise((resolve) => {
const metrics: any = {};
// @ts-ignore
import('https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js').then((webVitals) => {
webVitals.onLCP((metric: any) => {
metrics.lcp = metric.value;
});
webVitals.onFID((metric: any) => {
metrics.fid = metric.value;
});
webVitals.onCLS((metric: any) => {
metrics.cls = metric.value;
resolve(metrics);
});
});
});
});
// Assert thresholds
expect(vitals.lcp).toBeLessThan(2500);
expect(vitals.fid).toBeLessThan(100);
expect(vitals.cls).toBeLessThan(0.1);
});
test('should measure LCP', async ({ page }) => {
await page.goto('http://localhost:3000');
const lcp = await page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
resolve(lastEntry.renderTime || lastEntry.loadTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
});
console.log('LCP:', lcp);
expect(lcp).toBeLessThan(2500);
});
test('should measure CLS', async ({ page }) => {
await page.goto('http://localhost:3000');
// Interact with page to trigger layout shifts
await page.mouse.wheel(0, 1000);
await page.waitForTimeout(1000);
const cls = await page.evaluate(() => {
return new Promise((resolve) => {
let clsValue = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value;
}
}
resolve(clsValue);
}).observe({ type: 'layout-shift', buffered: true });
});
});
console.log('CLS:', cls);
expect(cls).toBeLessThan(0.1);
});
});Real User Monitoring (RUM)
// src/lib/web-vitals.ts
import { onCLS, onFID, onLCP, onFCP, onTTFB, Metric } from 'web-vitals';
function sendToAnalytics(metric: Metric) {
// Send to your analytics endpoint
const body = JSON.stringify(metric);
// Use `navigator.sendBeacon()` if available, falling back to `fetch()`
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
fetch('/analytics', {
body,
method: 'POST',
keepalive: true
});
}
}
// Measure and send all Web Vitals
onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);k6 for Load Testing
k6 is a modern load testing tool for testing APIs and microservices.
Installation
# macOS
brew install k6
# Linux
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update
sudo apt-get install k6
# Docker
docker pull grafana/k6Basic Load Test
// load-test.js
import http from 'k6/http';
import { sleep, check } from 'k6';
import { Rate } from 'k6/metrics';
// Custom metrics
const errorRate = new Rate('errors');
// Test configuration
export const options = {
// Stages (ramp up/down)
stages: [
{ duration: '30s', target: 20 }, // Ramp up to 20 users
{ duration: '1m30s', target: 20 }, // Stay at 20 users
{ duration: '20s', target: 0 }, // Ramp down to 0 users
],
// Thresholds (pass/fail criteria)
thresholds: {
'http_req_duration': ['p(95)<500'], // 95% of requests must complete below 500ms
'http_req_failed': ['rate<0.01'], // Error rate must be less than 1%
'errors': ['rate<0.1'], // Custom error rate
},
};
export default function () {
// Test homepage
const homeResponse = http.get('http://localhost:3000/');
check(homeResponse, {
'status is 200': (r) => r.status === 200,
'page loads in <500ms': (r) => r.timings.duration < 500,
'has correct title': (r) => r.body.includes('<title>My App</title>'),
}) || errorRate.add(1);
sleep(1);
// Test API endpoint
const apiResponse = http.get('http://localhost:3000/api/users');
check(apiResponse, {
'api status is 200': (r) => r.status === 200,
'api returns array': (r) => Array.isArray(JSON.parse(r.body)),
}) || errorRate.add(1);
sleep(1);
}Advanced k6 Scenarios
// advanced-load-test.js
import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { Counter, Trend } from 'k6/metrics';
// Custom metrics
const loginDuration = new Trend('login_duration');
const checkoutErrors = new Counter('checkout_errors');
export const options = {
scenarios: {
// Constant load
constant_load: {
executor: 'constant-vus',
vus: 50,
duration: '5m',
},
// Ramping load
ramping_load: {
executor: 'ramping-vus',
startVUs: 0,
stages: [
{ duration: '2m', target: 100 },
{ duration: '5m', target: 100 },
{ duration: '2m', target: 200 },
{ duration: '5m', target: 200 },
{ duration: '2m', target: 0 },
],
gracefulRampDown: '30s',
},
// Spike test
spike_test: {
executor: 'ramping-arrival-rate',
startRate: 50,
timeUnit: '1s',
preAllocatedVUs: 500,
maxVUs: 1000,
stages: [
{ duration: '2m', target: 50 },
{ duration: '30s', target: 500 }, // Spike
{ duration: '2m', target: 50 },
],
},
},
thresholds: {
'http_req_duration': ['p(99)<1500', 'p(95)<1000', 'p(90)<800'],
'http_req_failed': ['rate<0.01'],
'login_duration': ['p(95)<1000'],
'checkout_errors': ['count<10'],
},
};
export default function () {
// User flow: Browse -> Login -> Add to Cart -> Checkout
group('Browse Products', () => {
const res = http.get('http://localhost:3000/products');
check(res, {
'products loaded': (r) => r.status === 200,
});
sleep(1);
});
group('Login', () => {
const loginData = {
email: 'test@example.com',
password: 'password123',
};
const res = http.post(
'http://localhost:3000/api/auth/login',
JSON.stringify(loginData),
{
headers: { 'Content-Type': 'application/json' },
}
);
loginDuration.add(res.timings.duration);
check(res, {
'login successful': (r) => r.status === 200,
'has auth token': (r) => JSON.parse(r.body).token !== undefined,
});
sleep(2);
});
group('Add to Cart', () => {
const res = http.post('http://localhost:3000/api/cart/add', {
productId: '123',
quantity: 1,
});
check(res, {
'added to cart': (r) => r.status === 200,
});
sleep(1);
});
group('Checkout', () => {
const res = http.post('http://localhost:3000/api/checkout');
if (!check(res, { 'checkout successful': (r) => r.status === 200 })) {
checkoutErrors.add(1);
}
sleep(2);
});
}Running k6 Tests
# Run test
k6 run load-test.js
# Run with custom VUs and duration
k6 run --vus 100 --duration 30s load-test.js
# Run with environment variables
k6 run -e BASE_URL=https://staging.example.com load-test.js
# Output to InfluxDB
k6 run --out influxdb=http://localhost:8086/k6 load-test.js
# Generate HTML report
k6 run --out json=test.json load-test.jsk6 can simulate thousands of virtual users on a single machine, making it perfect for load and stress testing.
Artillery for API Load Testing
Artillery is a modern load testing toolkit for APIs, microservices, and async messaging.
Installation
npm install --save-dev artilleryBasic Configuration
# artillery-config.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 60
arrivalRate: 5
name: Warm up
- duration: 120
arrivalRate: 10
name: Sustained load
- duration: 60
arrivalRate: 20
name: Spike
payload:
path: 'users.csv'
fields:
- email
- password
variables:
apiKey: 'test-api-key'
scenarios:
- name: 'User Flow'
flow:
- get:
url: '/'
expect:
- statusCode: 200
- contentType: text/html
- post:
url: '/api/auth/login'
json:
email: '{{ email }}'
password: '{{ password }}'
capture:
- json: '$.token'
as: 'authToken'
expect:
- statusCode: 200
- get:
url: '/api/users/profile'
headers:
Authorization: 'Bearer {{ authToken }}'
expect:
- statusCode: 200
- hasProperty: 'id'
- think: 3
- post:
url: '/api/orders'
headers:
Authorization: 'Bearer {{ authToken }}'
json:
items:
- productId: '123'
quantity: 2
expect:
- statusCode: 201Advanced Artillery Test
# artillery-advanced.yml
config:
target: 'http://localhost:3000'
phases:
- duration: 300
arrivalRate: 1
rampTo: 50
name: Ramp up load
plugins:
expect: {}
metrics-by-endpoint: {}
processor: './helpers.js'
scenarios:
- name: 'E-commerce Flow'
weight: 70
flow:
- function: 'generateRandomUser'
- post:
url: '/api/register'
json:
email: '{{ $randomEmail }}'
password: '{{ $randomPassword }}'
- think: 2
- get:
url: '/api/products'
- loop:
- get:
url: '/api/products/{{ $randomProductId }}'
- think: 1
count: 3
- post:
url: '/api/cart/add'
json:
productId: '{{ $randomProductId }}'
quantity: '{{ $randomQuantity }}'
- name: 'API Health Check'
weight: 30
flow:
- get:
url: '/health'
expect:
- statusCode: 200
- contentType: application/json// helpers.js
module.exports = {
generateRandomUser: function(context, events, done) {
context.vars.$randomEmail = `user${Date.now()}@example.com`;
context.vars.$randomPassword = Math.random().toString(36);
context.vars.$randomProductId = Math.floor(Math.random() * 1000);
context.vars.$randomQuantity = Math.floor(Math.random() * 5) + 1;
return done();
}
};Running Artillery
# Run test
artillery run artillery-config.yml
# Run with report
artillery run --output report.json artillery-config.yml
artillery report report.json
# Quick test
artillery quick --duration 60 --rate 10 http://localhost:3000
# Run in Docker
docker run --rm -v $(pwd):/scripts artilleryio/artillery:latest run /scripts/artillery-config.ymlPerformance Budgets
Performance budgets are limits on metrics that affect site performance.
Configuration
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
// Time-based budgets (milliseconds)
'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'speed-index': ['error', { maxNumericValue: 3000 }],
// Resource budgets (bytes)
'resource-summary:script:size': ['error', { maxNumericValue: 200000 }],
'resource-summary:stylesheet:size': ['error', { maxNumericValue: 50000 }],
'resource-summary:image:size': ['error', { maxNumericValue: 500000 }],
'resource-summary:font:size': ['error', { maxNumericValue: 100000 }],
'resource-summary:total:size': ['error', { maxNumericValue: 1000000 }],
// Count budgets
'resource-summary:script:count': ['warn', { maxNumericValue: 15 }],
'resource-summary:third-party:count': ['warn', { maxNumericValue: 10 }],
}
}
}
};Bundle Size Monitoring
// package.json
{
"scripts": {
"build": "vite build",
"analyze": "vite-bundle-visualizer",
"size-limit": "size-limit"
},
"size-limit": [
{
"path": "dist/assets/index-*.js",
"limit": "200 KB"
},
{
"path": "dist/assets/vendor-*.js",
"limit": "500 KB"
}
]
}Best Practices
1. Test Realistic Scenarios
// ✅ Good: Realistic user flow
export default function() {
// Browse (most users)
http.get('http://localhost:3000/products');
sleep(randomIntBetween(1, 5));
// Some users add to cart
if (Math.random() < 0.3) {
http.post('http://localhost:3000/api/cart/add', {...});
sleep(randomIntBetween(2, 4));
// Fewer users checkout
if (Math.random() < 0.5) {
http.post('http://localhost:3000/api/checkout', {...});
}
}
}2. Monitor Multiple Metrics
- Response time (p50, p95, p99)
- Throughput (requests/sec)
- Error rate
- Resource utilization
3. Set Meaningful Thresholds
thresholds: {
'http_req_duration': ['p(95)<500', 'p(99)<1000'],
'http_req_failed': ['rate<0.01'],
}Related Topics
- Best Practices - Testing guidelines
- CI/CD Integration - Automated testing
- E2E Testing - End-to-end tests