TestingPerformance Testing

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

TypePurposeExample Tools
Load TestingTest under expected loadk6, Artillery
Stress TestingTest beyond capacityk6, JMeter
Spike TestingTest sudden traffic spikesk6, Gatling
Endurance TestingTest over extended timek6, LoadRunner
Browser PerformanceTest frontend metricsLighthouse, 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/cli

Configuration

// 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

MetricWhat it MeasuresGoodNeeds ImprovementPoor
LCP (Largest Contentful Paint)Loading performance≤ 2.5s2.5s - 4s> 4s
FID (First Input Delay)Interactivity≤ 100ms100ms - 300ms> 300ms
CLS (Cumulative Layout Shift)Visual stability≤ 0.10.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/k6

Basic 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.js

k6 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 artillery

Basic 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: 201

Advanced 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.yml

Performance 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'],
}


Additional Resources