API Performance Testing: Load, Stress and Spike Tests with k6
An API that answers in 120 ms for one tester can take 9 seconds when 20,000 shoppers hit it at midnight on sale day, or simply fall over. Performance testing finds those limits before your users do. In this lesson you’ll learn the metrics that matter (and why averages lie), the main types of performance tests, how to write a load test in k6, how to add quick latency checks to your Playwright suite, and how to read the results like an engineer.
The Metrics That Matter
| Metric | Meaning | Example target |
|---|---|---|
| Response time (latency) | Time from sending the request to receiving the full response | p95 < 800 ms |
| Percentiles (p50, p90, p95, p99) | p95 = 95% of requests were faster than this | p99 < 1.5 s |
| Throughput | Requests per second (RPS) the system handles | 500 RPS sustained |
| Error rate | % of failed requests (5xx, timeouts, wrong responses) | < 1% |
| Concurrent users / VUs | Simultaneous virtual users | 2,000 |
| Resource usage | CPU, memory, DB connections, queue length on servers | CPU < 70% at peak |
| Time to first byte (TTFB) | Server think time before the response starts | Diagnoses slow backends |
Types of Performance Tests
| Type | Load pattern | Question it answers | Real example |
|---|---|---|---|
| Smoke (performance) | 1 to 5 users, short | Does the script work, are basics fast? | Run on every build |
| Load | Ramp to expected peak, hold | Do we meet SLAs at normal peak? | Evening dinner rush for a food app |
| Stress | Keep increasing past peak | Where’s the breaking point, how does it fail? | Find max orders/second |
| Spike | Sudden jump and drop | Do we survive and recover? | Flash sale opens at 12:00, ticket booking opens |
| Soak / endurance | Moderate load for hours | Memory leaks, connection exhaustion | 24-hour run before a festive season |
| Scalability | Load while adding servers | Does adding capacity actually help? | Auto-scaling validation |
Before You Load Test: Ground Rules
- Define goals: “p95 < 800 ms, errors < 1% at 300 RPS for 30 minutes”.
- Model real traffic: which endpoints, in what mix (e.g. 70% browse, 20% search, 10% checkout), think time between calls.
- Production-like environment: similar data volumes, configuration and infrastructure.
- Test data: enough unique users, products and tokens so caches don’t make results unrealistically good.
- Monitoring: watch server metrics (APM, dashboards) during the test, not just client-side numbers.
Your First k6 Load Test
k6 is a free, open-source load testing tool where scripts are written in JavaScript. Install it from k6.io (Windows, macOS, Linux or Docker), then save this as users-load.js:
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE = 'https://sqatools.in/wp-json/sqa-api/v1';
export const options = {
vus: 3, // 3 virtual users - deliberately tiny for the shared practice API
duration: '30s',
thresholds: {
http_req_duration: ['p(95)<1500'], // 95% of requests faster than 1.5 s
http_req_failed: ['rate<0.01'], // less than 1% errors
checks: ['rate>0.99'],
},
};
export default function () {
const res = http.get(`${BASE}/users?per_page=5`);
check(res, {
'status is 200': (r) => r.status === 200,
'has 5 users': (r) => r.json('data').length === 5,
});
sleep(1); // think time: a real user pauses between actions
}
k6 run users-load.js
k6 prints a summary including http_req_duration (avg, min, med, max, p90, p95), http_reqs (throughput), http_req_failed and whether each threshold passed. A failed threshold gives a non-zero exit code, so k6 can gate a CI pipeline.
Ramping load with stages (load, stress, spike)
// For YOUR performance environment, not the practice API
export const options = {
stages: [
{ duration: '2m', target: 100 }, // ramp up to 100 users
{ duration: '10m', target: 100 }, // hold: load test
{ duration: '2m', target: 400 }, // push beyond: stress
{ duration: '30s', target: 1500 }, // sudden spike
{ duration: '2m', target: 0 }, // ramp down: does it recover?
],
thresholds: { http_req_duration: ['p(95)<800'], http_req_failed: ['rate<0.01'] },
};
A realistic user journey
import http from 'k6/http';
import { check, group, sleep } from 'k6';
export default function () {
let token;
group('login', () => {
const res = http.post(`${__ENV.BASE}/auth/login`,
JSON.stringify({ username: __ENV.USER, password: __ENV.PASS }),
{ headers: { 'Content-Type': 'application/json' } });
check(res, { 'login 200': (r) => r.status === 200 });
token = res.json('access_token');
});
sleep(2);
group('browse and order', () => {
const auth = { headers: { Authorization: `Bearer ${token}` } };
check(http.get(`${__ENV.BASE}/products?category=books`, auth), { 'browse 200': (r) => r.status === 200 });
sleep(3);
// ... add to cart, checkout
});
}
Run with k6 run -e BASE=https://perf.example.com/api -e USER=perf_user_1 -e PASS=... journey.js; secrets come from the environment, not the script.
Reading Results: A Worked Example
| Stage | Users | RPS | p95 | Errors | Interpretation |
|---|---|---|---|---|---|
| Load | 100 | 310 | 420 ms | 0.1% | Meets SLA comfortably |
| Stress | 400 | 520 | 2.8 s | 0.4% | Throughput flattens while latency climbs: saturation point around 500 RPS |
| Spike | 1,500 | 540 | 9.5 s | 18% (503/504) | Overload; check auto-scaling and queueing |
| Recovery | 0 | – | – | – | Latency back to normal within 1 minute = good |
The pattern to recognise: when throughput stops growing but response time keeps rising, you’ve found a bottleneck (CPU, DB connection pool, a slow query, a lock, a downstream API). Pair the timing with server metrics to locate it.
Rate Limits, Timeouts and Retries
Performance testing also covers how the API protects itself and how clients behave:
- Rate limits: the practice API’s
/rate-limitendpoint allows 5 calls per minute, then returns429withRetry-AfterandX-RateLimit-*headers. - Slow dependencies:
/delay/{seconds}waits up to 10 seconds, ideal for testing client timeouts. - Client behaviour: does the app back off on 429/503, or hammer the server harder (a “retry storm”)?
https://sqatools.in/wp-json/sqa-api/v1/delay/2curl -i \
"https://sqatools.in/wp-json/sqa-api/v1/delay/2"Quick Latency Checks Inside Your Playwright Suite
You don’t need a load tool to catch obvious regressions. A small sequential sample in your functional suite flags an endpoint that suddenly got slow:
import { test, expect } from '@playwright/test';
const BASE = 'https://sqatools.in/wp-json/sqa-api/v1/';
function percentile(values: number[], p: number) {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)];
}
test('GET users: p95 under 2 s over 15 calls', async ({ request }) => {
const timings: number[] = [];
for (let i = 0; i < 15; i++) {
const start = performance.now();
const res = await request.get(`${BASE}users?per_page=5`);
timings.push(performance.now() - start);
expect(res.status()).toBe(200);
}
const p95 = percentile(timings, 95);
test.info().annotations.push({ type: 'p95 ms', description: p95.toFixed(0) });
expect(p95).toBeLessThan(2_000);
});
test('client gives up on a slow endpoint', async ({ request }) => {
const start = Date.now();
await expect(request.get(`${BASE}delay/5`, { timeout: 2_000 })).rejects.toThrow(/Timeout/);
expect(Date.now() - start).toBeLessThan(3_500);
});
test('rate limit answers 429 with Retry-After', async ({ request }) => {
let last;
for (let i = 0; i < 6; i++) last = await request.get(`${BASE}rate-limit`);
expect(last!.status()).toBe(429);
expect(Number(last!.headers()['retry-after'])).toBeGreaterThan(0);
});
These are not load tests (one user, sequential calls); they’re cheap guards against performance regressions that run on every build. The rate-limit test shares a per-IP counter, so don’t run it in parallel with itself.
Tools Compared
| Tool | Scripts in | Strengths |
|---|---|---|
| k6 | JavaScript | Developer-friendly, thresholds for CI, low resource use, cloud option |
| JMeter | GUI / XML | Mature, huge plugin ecosystem, many protocols, widely known in service companies |
| Gatling | Scala/Java/Kotlin/JS | High performance, great HTML reports |
| Locust | Python | Easy for Python teams, distributed |
| Artillery | YAML/JS | Quick configs, WebSocket and Socket.IO support |
Common Performance Testing Mistakes
- Reporting averages only.
- Testing from a laptop on office Wi-Fi: the load generator becomes the bottleneck.
- Using the same user and same product id for every request, so caches hide real costs.
- No think time: 100 VUs without pauses behave like thousands of real users.
- Ignoring errors: a fast 500 isn’t a fast success. Check responses in the script.
- One run and done: compare with a baseline on every release.
Interview Questions: API Performance Testing
What is API performance testing?
Testing how an API behaves under various loads by measuring response time, throughput, error rate and resource usage, to verify it meets performance requirements and to find bottlenecks and limits.
What is the difference between load, stress, spike and soak testing?
Load testing uses expected peak traffic; stress testing pushes beyond capacity to find the breaking point; spike testing applies sudden large jumps; soak testing runs moderate load for a long time to reveal leaks and degradation.
Why use p95 instead of average response time?
Averages hide slow outliers. p95 shows the time within which 95% of requests complete, reflecting the experience of the slowest typical users, which is what SLAs usually target.
What is throughput?
The number of requests (or transactions) the system processes per unit of time, usually requests per second.
What are thresholds in k6?
Pass/fail criteria on metrics, such as http_req_duration p(95)<800 or http_req_failed rate<0.01. If any threshold fails, k6 exits with a non-zero code, failing the CI job.
What is think time and why is it important?
Pauses between actions that simulate real user behaviour. Without think time, a small number of virtual users generate unrealistically high request rates.
How do you identify a bottleneck from results?
When increasing load no longer increases throughput but response times and errors rise, the system is saturated. Correlate with server metrics (CPU, memory, DB connections, slow queries, downstream latency) to find the cause.
Which tools have you used for API performance testing?
Mention one or two with specifics, for example: “k6 for scripted load tests with thresholds in GitHub Actions, and JMeter for a legacy SOAP service, monitored with Grafana dashboards.”
How do you test rate limiting?
Send requests beyond the documented limit within the time window and verify 429 with Retry-After and rate limit headers, that the limit resets after the window, and that limits apply per user, key or IP as designed.
What is an SLA or SLO in performance testing?
A Service Level Agreement or Objective defining measurable targets, such as p95 latency under 800 ms and 99.9% availability, that performance tests verify.