Back to Blog
Performance Testing
December 20, 2023
9 min read

Performance Testing Essentials: Load, Stress, and Scalability

NorthQA TeamNorthQA Team

Introduction

Performance testing ensures your application can handle real-world usage without degrading user experience. This guide covers the essentials of load testing, stress testing, and scalability validation.

Why Performance Testing Matters

Performance issues lead to:

  • Poor user experience
  • Lost revenue
  • Damaged reputation
  • Infrastructure waste
  • Scaling problems
Statistic: A 1-second delay in page load time can result in a 7% reduction in conversions.

Types of Performance Testing

Understanding different types of performance testing helps you choose the right approach for your application's needs. Each type serves a distinct purpose in validating your system's capabilities.

1. Load Testing

Load testing validates how your application performs under expected, normal production load. This is the most common type of performance testing and helps establish a baseline for acceptable performance. You gradually increase the number of concurrent users or requests to see how the system responds as it approaches its capacity. Load testing answers critical questions like: "Can my system handle 1,000 concurrent users?" or "What is the maximum throughput my API can achieve?"

2. Stress Testing

Stress testing pushes your system beyond its normal operating capacity to discover its breaking point. This test intentionally increases load beyond expected levels to identify system limits and see how gracefully it degrades. When a system is under stress, it might queue requests, increase response times, or eventually fail. Understanding these failure modes helps you prepare mitigation strategies and understand your system's resilience boundaries.

3. Spike Testing

Spike testing simulates sudden, dramatic increases in traffic—like when a viral social media post drives unexpected attention to your application. Unlike load testing which gradually increases users, spike testing jumps from normal load to very high load almost instantaneously. This reveals whether your auto-scaling infrastructure responds quickly enough and if your system can handle unexpected traffic bursts without timing out or losing data.

4. Endurance Testing (Soak Testing)

Endurance testing runs your application under sustained load for an extended period—sometimes 24 hours or more. This type reveals problems that only appear over time, such as memory leaks, connection pool exhaustion, disk space issues, or cascading failures. Many subtle performance bugs only surface after an application has been running continuously under load for hours.

5. Scalability Testing

Scalability testing measures how well your system can grow. It tests whether adding more servers, increasing database capacity, or upgrading infrastructure actually improves performance. Scalability testing helps you understand the relationship between resources invested and performance gains, ensuring your scaling strategy is cost-effective.

Key Performance Metrics

Measuring performance requires tracking the right metrics. Rather than focusing solely on average response time, modern performance testing uses percentile-based metrics to understand the full picture of user experience. Here are the critical metrics you should monitor:

Response Time Percentiles: Instead of average response time, track percentiles (p50, p95, p99). The p50 (median) shows typical user experience, while p95 and p99 show what your slowest users experience. If your p99 is 5 seconds while p50 is 500ms, it means 1% of users wait significantly longer—often a sign of resource bottlenecks.

Throughput: Measured in requests per second or transactions per second, throughput shows how many operations your system can process. This helps you understand capacity and predict when you'll need to scale.

Error Rates: Track both overall error rate and specific failure types. A 0.1% error rate might seem acceptable, but if you serve 1 million requests daily, that's 1,000 errors per day. Zero errors under load is ideal, but knowing your acceptable threshold is critical for SLAs.

Resource Utilization: Monitor CPU, memory, disk I/O, and network bandwidth. These metrics help identify bottlenecks. If CPU is at 90% while memory is at 20%, your bottleneck is CPU-bound and you need optimization, not more RAM.

const performanceMetrics = {
  responseTime: {
    p50: '< 200ms',    // Median - typical user
    p95: '< 500ms',    // 95th percentile - most users
    p99: '< 1000ms',   // 99th percentile - slowest users
  },
  throughput: {
    requestsPerSecond: '> 1000',
    transactionsPerSecond: '> 500',
  },
  errors: {
    errorRate: '< 0.1%',
    timeoutRate: '< 0.01%',
  },
  resources: {
    cpuUsage: '< 70%',    // Leave headroom for spikes
    memoryUsage: '< 80%',  // Prevent OOM errors
    diskIO: '< 80%',       // Prevent disk saturation
    networkBandwidth: '< 70%', // Prevent network bottlenecks
  }
};

Load Testing with k6

k6 is a modern, developer-friendly load testing tool that makes it easy to write and run performance tests. Unlike traditional load testing tools with GUI complexity, k6 uses simple JavaScript code, making tests version-controllable and easy to integrate into CI/CD pipelines.

Basic Load Test

A basic load test simulates a realistic ramp-up of users over time. Rather than throwing all users at your system at once, you gradually increase the load to see how performance degrades. This test configuration ramps up to 100 users, sustains that load, then ramps up to 200 users, and finally ramps down to verify your system recovers properly.

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 100 },   // Ramp up to 100 users
    { duration: '5m', target: 100 },   // Stay at 100 users
    { duration: '2m', target: 200 },   // Ramp up to 200 users
    { duration: '5m', target: 200 },   // Stay at 200 users
    { duration: '2m', target: 0 },     // Ramp down to 0 users
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95% of requests must complete below 500ms
    http_req_failed: ['rate<0.01'],    // Error rate must be below 1%
  },
};

export default function () {
  const response = http.get('https://api.example.com/users');

  check(response, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  sleep(1);
}

Stress Test

Stress testing intentionally overloads your system to find its breaking point and understand degradation patterns. You'll gradually push load beyond normal capacity to see at what point errors appear, timeouts occur, or the system becomes unresponsive. This helps you understand your system's resilience and plan for catastrophic failure scenarios. The recovery phase is equally important—it shows whether your system can gracefully recover once load decreases.

export const options = {
  stages: [
    { duration: '2m', target: 100 },   // Below normal load
    { duration: '5m', target: 100 },
    { duration: '2m', target: 200 },   // Normal load
    { duration: '5m', target: 200 },
    { duration: '2m', target: 300 },   // Around breaking point
    { duration: '5m', target: 300 },
    { duration: '2m', target: 400 },   // Beyond breaking point
    { duration: '5m', target: 400 },
    { duration: '10m', target: 0 },    // Recovery - critical phase
  ],
};

Spike Test

Spike testing is crucial for applications that experience sudden traffic bursts. Rather than gradually increasing load, spike tests jump from normal load directly to extremely high load. This reveals whether your auto-scaling infrastructure responds fast enough and whether your application has request queuing or circuit breaker mechanisms to handle the sudden surge. Real-world examples include product launches, viral social media posts, or breaking news coverage.

export const options = {
  stages: [
    { duration: '10s', target: 100 },  // Normal load
    { duration: '1m', target: 100 },
    { duration: '10s', target: 1400 }, // Spike to 1400 users
    { duration: '3m', target: 1400 },  // Stay at spike
    { duration: '10s', target: 100 },  // Return to normal
    { duration: '3m', target: 100 },
    { duration: '10s', target: 0 },
  ],
};

Advanced Testing Scenarios

Real-world applications are complex, with multiple endpoints and authentication requirements. Advanced testing scenarios go beyond simple GET requests to mimic realistic user behavior and test critical workflows under load.

API Endpoint Testing

Testing multiple endpoints simultaneously is more realistic than testing a single endpoint in isolation. This test uses k6's batch functionality to make multiple requests in parallel, simulating real user behavior where a single user interaction often triggers multiple API calls. For example, loading a user's dashboard might require fetching user data, orders, products, and recommendations all at once.

import http from 'k6/http';
import { check } from 'k6';

export default function () {
  const url = 'https://api.example.com';

  // Test multiple endpoints
  const responses = http.batch([
    ['GET', `${url}/users`],
    ['GET', `${url}/products`],
    ['POST', `${url}/orders`, JSON.stringify({
      product_id: 123,
      quantity: 1
    }), {
      headers: { 'Content-Type': 'application/json' }
    }],
  ]);

  check(responses[0], {
    'users endpoint status 200': (r) => r.status === 200,
  });

  check(responses[1], {
    'products endpoint status 200': (r) => r.status === 200,
  });

  check(responses[2], {
    'order created': (r) => r.status === 201,
  });
}

Authentication Testing

Many APIs require authentication, and authentication systems are often bottlenecks during load testing. Testing authenticated endpoints under load reveals whether your authentication mechanism (login, token generation, session management) can handle the concurrent load. Some authentication systems become extremely slow under stress, creating a cascade of failures. This test simulates real user behavior: logging in, receiving a token, and then making authenticated requests.

import http from 'k6/http';
import { check } from 'k6';

export default function () {
  // Login
  const loginRes = http.post('https://api.example.com/login', {
    username: 'testuser',
    password: 'testpass',
  });

  const authToken = loginRes.json('token');

  // Use authenticated endpoint
  const params = {
    headers: {
      'Authorization': `Bearer ${authToken}`,
    },
  };

  const response = http.get('https://api.example.com/protected', params);

  check(response, {
    'authenticated request successful': (r) => r.status === 200,
  });
}

Database Performance Testing

Databases are often the bottleneck in performance-constrained systems. Connection pooling, query optimization, and index design significantly impact application performance. Database performance testing focuses on how your application interacts with the database under load and whether your connection pool configuration is optimal.

Connection Pool Testing

Database connection pooling is critical for performance. Each database connection consumes server resources, and creating new connections is expensive. When under load, if your connection pool is exhausted, new requests must wait for an available connection, causing delays. This test verifies that your connection pool size is adequate and that database queries don't timeout even when the system is busy. It gradually increases the number of concurrent virtual users making database-heavy requests.

// Test database connection pool under load
export const options = {
  scenarios: {
    database_load: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '1m', target: 50 },
        { duration: '5m', target: 50 },
        { duration: '1m', target: 100 },
        { duration: '5m', target: 100 },
      ],
    },
  },
};

export default function () {
  const response = http.get('https://api.example.com/database-heavy-query');

  check(response, {
    'query completed': (r) => r.status === 200,
    'query time acceptable': (r) => r.timings.duration < 2000,
  });
}

Monitoring During Tests

Running a performance test without monitoring is like flying blind. While k6 provides detailed metrics about what your application is experiencing, you also need to monitor the servers and infrastructure supporting your application. This holistic view helps you identify whether bottlenecks are in your application code, infrastructure, or external dependencies.

Key Metrics to Monitor

Different metrics serve different purposes. Application metrics tell you how users experience your system, infrastructure metrics show you resource utilization, and database metrics reveal query-level issues. Monitoring all three simultaneously helps you pinpoint the exact source of performance problems.

# Application Metrics
- Response times (p50, p95, p99)
- Throughput (requests/sec)
- Error rates
- Active connections

# Infrastructure Metrics
- CPU usage
- Memory usage
- Disk I/O
- Network bandwidth

# Database Metrics
- Query execution time
- Connection pool usage
- Lock waits
- Cache hit ratio

Using Prometheus + Grafana

# docker-compose.yml
version: '3'
services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

Performance Testing in CI/CD

# GitHub Actions example
name: Performance Tests

on:
  push:
    branches: [ main ]

jobs:
  performance-test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Deploy to test environment
      run: ./deploy-test.sh

    - name: Install k6
      run: |
        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

    - name: Run load test
      run: k6 run --out json=results.json tests/load-test.js

    - name: Check performance thresholds
      run: |
        node scripts/check-performance.js results.json

    - name: Upload results
      uses: actions/upload-artifact@v3
      with:
        name: performance-results
        path: results.json

Best Practices

1. Define Clear Performance Goals

Before you start testing, establish specific, measurable performance goals. These goals should align with business requirements and user expectations. Vague goals like "fast enough" lead to guesswork; specific goals like "p95 response time < 500ms" are testable and achievable.

Example Goals:
- Homepage loads in < 2 seconds (p95)
- API responds in < 500ms for 95% of requests
- System handles 10,000 concurrent users with < 1% error rate
- Error rate stays below 0.1% under normal load
- 99th percentile response time < 2 seconds

2. Test in Production-Like Environment

Testing in production or staging is crucial—your laptop's performance is irrelevant. Production-like testing reveals real infrastructure constraints, network latency, and third-party service limitations. Use the same hardware configuration, network setup, and data volumes as production. If you test against production, use a read-only or dummy data account to avoid contaminating real data.

  • Match production infrastructure (CPU, RAM, storage type)
  • Use production-like data volumes (millions of records, not thousands)
  • Simulate real user behavior and geographies
  • Include third-party dependencies (payment gateways, analytics)

3. Gradual Load Increase

Never throw all traffic at your system at once. Gradual ramp-up provides more reliable data and safer testing. It allows you to see at what point performance starts degrading, making it easier to identify the exact breaking point.

Bad:  0 → 10,000 users instantly (massive spike, unrealistic)
Good: 0 → 100 → 500 → 1,000 → 5,000 → 10,000 gradually

4. Think Time and Pacing

Real users don't hammer your API instantly. They read content, make decisions, and pause between actions. Simulating realistic think time makes your tests more accurate and reveals different bottlenecks than continuous hammering. Think time between requests shows how your system handles bursty traffic patterns.

// Realistic user behavior includes pauses
export default function () {
  // User arrives at site
  http.get('https://example.com');
  sleep(2); // User reads content, looks at images

  // User searches for product
  http.get('https://example.com/search?q=product');
  sleep(3); // User reviews search results, filters options

  // User clicks on product
  http.get('https://example.com/product/123');
  sleep(5); // User reads reviews, checks price, decides
}

5. Ramp Down Period

The ramp-down phase is often overlooked but reveals important information. Does your system gracefully recover when load decreases? Are there connection leaks or resource cleanup issues? Does your auto-scaling infrastructure scale down properly? Including a ramp-down phase is essential for validating your system's resilience.

stages: [
  { duration: '5m', target: 1000 },  // Ramp up - gradually increase
  { duration: '10m', target: 1000 }, // Sustain - maintain peak load
  { duration: '5m', target: 0 },     // Ramp down - Important for recovery validation!
]

Common Performance Issues

Understanding common performance problems helps you avoid them in the first place. Many performance issues follow predictable patterns that can be identified and fixed systematically.

1. N+1 Query Problem

The N+1 query problem is a classic performance killer. It occurs when you fetch a parent record (1 query) and then fetch related records for each parent (N queries). Under load testing, if you load a list of 100 orders and fetch items for each order, you're executing 101 queries instead of 1. This compounds dramatically under load. The solution is to use joins or batch loading.

-- Bad: N+1 queries - 1 query for orders + N queries for items
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM items WHERE order_id = 1;
SELECT * FROM items WHERE order_id = 2;
-- ... (N more queries for N orders)

-- Good: Single query with JOIN
SELECT * FROM orders
LEFT JOIN items ON orders.id = items.order_id
WHERE orders.user_id = 1;

2. Missing Database Indexes

Without proper indexes, the database must scan entire tables to find matching records. A single slow query that works fine on 10,000 records might timeout with 10 million records. As your data grows, missing indexes become bottlenecks. Performance testing with production-like data volumes often reveals missing indexes because development databases are small.

-- Add indexes on frequently queried columns
CREATE INDEX idx_user_email ON users(email);
CREATE INDEX idx_order_created ON orders(created_at);
CREATE INDEX idx_order_user_id ON orders(user_id);

3. Inefficient Caching

Caching is one of the most impactful optimizations for performance. However, poor caching strategies (wrong TTL, no cache invalidation, caching non-idempotent operations) can make things worse. Effective caching requires understanding what data is expensive to compute and how often it changes.

// Good caching strategy
const cache = new Redis();

async function getUser(id) {
  const cached = await cache.get(`user:${id}`);
  if (cached) return JSON.parse(cached);

  const user = await db.users.findById(id);
  await cache.setex(`user:${id}`, 3600, JSON.stringify(user)); // 1 hour TTL
  return user;
}

// Remember to invalidate cache when data changes:
async function updateUser(id, data) {
  await db.users.update(id, data);
  await cache.del(`user:${id}`); // Invalidate old cache
}

4. Unoptimized API Responses

Returning unnecessary data increases payload size, network bandwidth, and parsing time. Clients might request only a few fields (id, name, email) but receive 50 fields including raw JSON, timestamps, and internal IDs. This is especially problematic for mobile clients with limited bandwidth.

// Bad: Returning everything
app.get('/users', async (req, res) => {
  const users = await db.users.findAll();
  res.json(users); // Sends all fields: id, name, email, password_hash, internal_notes, etc.
});

// Good: Return only needed fields
app.get('/users', async (req, res) => {
  const users = await db.users.findAll({
    attributes: ['id', 'name', 'email'] // Only the fields clients actually need
  });
  res.json(users);
});

Analyzing Results

Raw performance test results are just data—meaningful analysis requires asking the right questions and connecting the dots between metrics.

Key Questions to Ask

  1. At what load does the system degrade? - Does it start at 100 users or 10,000? This determines your actual capacity and helps with capacity planning.
  1. What is the bottleneck? (CPU, Memory, Database, Network) - If you're maxing out CPU at 50 concurrent users, your bottleneck is code optimization or algorithms. If database is at 95% while CPU is at 30%, you need query optimization or caching. Identifying the bottleneck tells you where to focus optimization effort.
  1. How does the system recover? - When load drops, does response time return to baseline immediately? If not, you likely have resource leaks, connection pool issues, or garbage collection problems.
  1. Are error rates acceptable? - A 0.01% error rate under stress might be acceptable for non-critical operations but unacceptable for payment processing. Different error types (timeouts vs. 500 errors) indicate different root causes.
  1. Can the system scale horizontally? - Does doubling servers double your capacity? If not, you have a shared bottleneck (database, cache, message queue) that doesn't scale.

Generate Reports

Convert raw k6 results into shareable reports for your team and stakeholders. Visual reports make it easier to identify trends and communicate findings.

# Generate HTML report from k6 results
k6 run --out json=results.json test.js
jq . results.json | k6-reporter --output report.html

Conclusion

Performance testing is essential for ensuring your application can handle real-world load. Rather than hoping your application is fast, you can measure it objectively. The key is to start early in development, test frequently, and treat performance improvements as a continuous process.

Performance testing reveals:

  • Whether your architecture scales
  • Which components are bottlenecks
  • How well your infrastructure responds to load
  • Whether you're meeting user expectations

Start with load testing against your API, gradually add complexity (authentication, multiple endpoints, database queries), and always validate production performance against your test predictions. If production behaves significantly differently than your tests, adjust your test scenarios.

Need help with performance testing strategy, test implementation, or analysis? Contact NorthQA to discuss how we can help optimize your application's performance and ensure it meets your users' expectations under real-world load.

Need Help with Quality Assurance?

At NorthQA, we provide comprehensive software quality assurance services to ensure your applications are robust, reliable, and bug-free.

Get in Touch