SUBID DASPORTFOLIO / 2026
000

SUBID

PORTRAIT // ORIGINAL[ 1 / 7 ]
WORK // RIDENOWW[ 2 / 7 ]
WORK // DEED POLL[ 3 / 7 ]
PORTRAIT // CYBERNETIC[ 4 / 7 ]
PORTRAIT // HOLOGRAM[ 5 / 7 ]
PORTRAIT // SKETCH[ 6 / 7 ]
PORTRAIT // GLITCH[ 7 / 7 ]
SUBID DAS
Back to Blog

Load Testing & Performance Engineering: Find Bottlenecks Before Users Do

11 min readSubid Das
performancedevopstestingload-testinginfrastructure

Your app works fine with 10 users. Then you launch. 10,000 users hit simultaneously. Everything breaks.

Load testing finds these limits before production. This guide covers practical tools and strategies.

Why Load Testing Matters

Users        | Response Time | Status
-------------|---------------|--------
10           | 50ms          | ✅
100          | 52ms          | ✅
1,000        | 150ms         | ⚠️
5,000        | 500ms         | ❌
10,000       | 5000ms+       | 🔥

Load testing reveals the breaking point. Then you fix it.

Tool 1: k6 (Modern, Easy)

k6 is scripted in JavaScript. Lightweight, fast feedback.

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';

export let options = {
  // Stage 1: Ramp up to 100 users over 30 seconds
  stages: [
    { duration: '30s', target: 100 },
    // Stage 2: Hold at 100 users for 1 minute
    { duration: '1m', target: 100 },
    // Stage 3: Ramp down over 10 seconds
    { duration: '10s', target: 0 }
  ],
  
  // Alert if error rate > 5%
  thresholds: {
    'http_req_duration': ['p(95)<500'], // 95th percentile < 500ms
    'http_req_failed': ['rate<0.05'],   // Error rate < 5%
  }
};

export default function() {
  // Test endpoint
  let response = http.get('http://localhost:3000/api/users');
  
  // Validate response
  check(response, {
    'status is 200': (r) => r.status === 200,
    'response time < 200ms': (r) => r.timings.duration < 200,
    'has data': (r) => r.json('data') !== null
  });
  
  sleep(1); // Wait 1 second between requests
}

Run test:

k6 run load-test.js

Output:

    data_received..............: 512 kB
    data_sent..................: 128 kB
    http_req_blocked...........: avg=1ms
    http_req_connecting........: avg=0ms
    http_req_duration..........: avg=125ms  p(95)=320ms  p(99)=450ms
    http_req_failed............: 0.00%  ✅
    http_req_receiving.........: avg=50ms
    http_req_sending...........: avg=5ms
    http_req_tls_handshaking...: avg=0ms
    http_req_waiting...........: avg=70ms
    http_reqs..................: 3600
    iteration_duration.........: avg=1.12s
    iterations.................: 3600
    vus........................: 0
    vus_max....................: 100

Tool 2: Locust (Python-Based, Flexible)

Locust simulates user behavior with Python classes.

# locustfile.py
from locust import HttpUser, task, between
import random

class WebsiteUser(HttpUser):
    wait_time = between(1, 3)  # Wait 1-3 seconds between requests
    
    @task(3)  # 3x more likely than other tasks
    def get_users(self):
        self.client.get("/api/users")
    
    @task(1)
    def get_user_detail(self):
        user_id = random.randint(1, 1000)
        response = self.client.get(f"/api/users/{user_id}")
    
    @task(1)
    def create_order(self):
        self.client.post(
            "/api/orders",
            json={"user_id": 42, "total": 99.99}
        )
    
    def on_start(self):
        # Login before requests
        response = self.client.post(
            "/api/login",
            json={"email": "test@example.com", "password": "password123"}
        )
        self.token = response.json()["token"]
        self.client.headers = {"Authorization": f"Bearer {self.token}"}

Run test:

locust -f locustfile.py --host=http://localhost:3000 --users=1000 --spawn-rate=50

Web UI shows live metrics:

RPS        | Failures | Avg Response | 95% Response | 99% Response
-----------|----------|--------------|--------------|---------------
150        | 0        | 125ms        | 320ms        | 450ms

Finding Bottlenecks

1. CPU Bottleneck

If CPU maxes out but memory/disk are fine:

# Check CPU usage
top -b -n 1 | grep app

# High CPU = inefficient algorithm or query
# Fix: Optimize hot paths, add caching

2. Memory Bottleneck

# Check memory leaks
free -h

# If memory grows over time:
# Fix: Find leak with profiler
node --inspect app.js
# Open chrome://inspect in Chrome DevTools

3. Database Bottleneck

-- Monitor queries
SELECT pid, usename, state, query, query_start
FROM pg_stat_activity
WHERE state = 'active';

-- Slow queries
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 10;

-- Fix: Add indexes, optimize queries, read replicas

4. I/O Bottleneck

# Check disk I/O
iostat -x 1

# If await > 20ms, disk is slow
# Fix: Use faster storage (SSD), optimize queries

5. Network Bottleneck

# Check bandwidth
iftop

# Network saturated?
# Fix: Enable compression, CDN, caching

Load Testing Strategy

Phase 1: Baseline (Single User)

export let options = {
  vus: 1,
  duration: '1m'
};

Expected: Sub-100ms response time.

Phase 2: Gradual Ramp-Up

export let options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '5m', target: 500 },
    { duration: '10m', target: 1000 },
    { duration: '5m', target: 0 }
  ]
};

Find where performance degrades.

Phase 3: Sustained Load

export let options = {
  stages: [
    { duration: '1m', target: 1000 },  // Ramp up
    { duration: '10m', target: 1000 }, // Hold (watch for memory leaks)
    { duration: '1m', target: 0 }      // Ramp down
  ]
};

Run 10 minutes. If memory/errors grow, you have a leak.

Phase 4: Stress Testing (Break It)

export let options = {
  stages: [
    { duration: '2m', target: 100 },
    { duration: '2m', target: 500 },
    { duration: '2m', target: 1000 },
    { duration: '2m', target: 2000 }, // Beyond expected peak
    { duration: '2m', target: 5000 }, // Stress it
    { duration: '1m', target: 0 }
  ]
};

Find breaking point. Your infrastructure should scale before breaking.

Performance Targets

MetricTarget
P95 latency< 500ms
P99 latency< 1000ms
Error rate< 0.1%
CPU usage< 80%
Memory usage< 80%
Disk I/O< 20ms

Real Example: Bottleneck Found

Load test reveals: Response time jumps at 500 users.

CPU:   30% → 85% (maxed)
Memory: 60% → 65% (stable)
DB:    100 conn → 100 conn (maxed)

Problem: Database connection pool exhausted.

Fix:

# Increase connection pool
resource "aws_db_instance" "postgres" {
  allocated_storage            = 100
  max_allocated_storage        = 200  # Auto-scaling
  multi_az                     = true # High availability
  
  # CloudWatch monitoring
  enable_cloudwatch_logs_exports = ["postgresql"]
}

# Add read replicas for reads
resource "aws_db_instance" "postgres_read" {
  replicate_source_db = aws_db_instance.postgres.id
}

# Connection pooling (PgBouncer)
resource "aws_elasticache_cluster" "pgbouncer" {
  engine           = "elasticache_cluster"
  parameter_group  = "pgbouncer"
  
  # Max 200 connections from app
  # PgBouncer -> 20 connections to database
}

After fix: 5,000 users, response time stays 125ms. ✅

Automation in CI/CD

# .github/workflows/performance.yml
name: Performance Test

on:
  push:
    branches: [main]

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Run k6 load test
        run: |
          k6 run load-test.js --vus 100 --duration 5m --out json=results.json
      
      - name: Check performance thresholds
        run: |
          # Fail if P95 > 500ms
          p95=$(jq '.metrics.http_req_duration.values.p95' results.json)
          if (( $(echo "$p95 > 500" | bc -l) )); then
            echo "Performance degradation detected: P95=$p95ms"
            exit 1
          fi
      
      - name: Comment on PR
        if: failure()
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '❌ Performance test failed: P95 latency > 500ms'
            })

Fail builds that degrade performance.

Checklist

  • Baseline test (1 user, single server)
  • Ramp-up test (gradual load increase)
  • Sustained load test (10+ minutes)
  • Stress test (find breaking point)
  • Identify bottlenecks (CPU, memory, DB, I/O)
  • Load test before each major deploy
  • Monitor baseline over time (detect regressions)
  • Automate in CI/CD
  • Document thresholds (when to alert)
  • Test failure scenarios (if DB goes down)

Conclusion

Load testing is your safety net. It prevents embarrassing outages and expensive infrastructure over-provisioning.

Start simple: 1 user, 10 users, 100 users. Watch metrics. Fix bottlenecks.

By the time users hit your app, you'll already know it works.

About the author

Subid Das is a cloud native engineer and open source contributor. Find more articles onthe blog.

Open to freelance, full-time, and interesting problems.

LET'S CONNECT