Perform Load Testing on APIs
How to simulate realistic traffic, measure response times, and identify bottlenecks using k6 and JMeter for APIs and web services.
Overview
Load testing measures how a system behaves under a specific volume of concurrent users or requests. Unlike functional tests that verify correctness, load tests reveal performance limits: at what point does response time degrade from 50ms to 2 seconds? At what load do errors spike from 0.1% to 10%? When does the database connection pool exhaust?
Modern load testing tools like k6 and JMeter let you define scenarios in code or configuration, run them from the command line or CI pipelines, and export detailed metrics. The solution below covers how to design realistic load tests, interpret the results, and iterate on performance improvements.
When to Use
Use this recipe when:
- Preparing for a product launch, marketing campaign, or seasonal traffic spike. See Connection Pooling for handling concurrent database connections.
- Migrating infrastructure and needing to validate the new platform handles equivalent load
- Establishing performance baselines and Service Level Objectives (SLOs). See Caching Strategies for reducing load on backend services.
- Investigating intermittent timeouts or errors that only appear under concurrent load. See Rate Limiting for protecting APIs under heavy traffic.
- Comparing performance before and after a major code or infrastructure change
Solution
k6 (JavaScript/Go-based)
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 }, // sustain load
{ duration: '2m', target: 200 }, // ramp up to 200 users
{ duration: '5m', target: 200 }, // sustain higher load
{ duration: '2m', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
http_req_failed: ['rate<0.01'], // error rate below 1%
},
};
export default function () {
const res = http.get('https://api.example.com/users');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
JMeter (XML/ GUI-based)
<!-- Test Plan: HTTP Request sampler with Thread Group -->
<ThreadGroup testname="API Load Test" guiclass="ThreadGroupGui">
<stringProp name="ThreadGroup.num_threads">100</stringProp>
<stringProp name="ThreadGroup.ramp_time">60</stringProp>
<stringProp name="ThreadGroup.duration">300</stringProp>
<elementProp name="HTTPsampler" elementType="HTTPSamplerProxy">
<stringProp name="HTTPSampler.domain">api.example.com</stringProp>
<stringProp name="HTTPSampler.path">/users</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
</elementProp>
</ThreadGroup>
Analyzing Results (k6)
http_req_duration..............: avg=234ms min=45ms med=198ms max=1.2s p(90)=412ms p(95)=567ms
http_req_failed................: 0.23%
data_received..................: 12 MB
iterations.....................: 12000
Explanation
- Virtual Users (VUs): Simulated concurrent users making requests. 100 VUs does not mean 100 requests per second — it depends on think time (
sleep) and response latency. - Ramp-up: Gradually increasing VUs prevents a sudden thundering herd that would distort results. A 2-minute ramp to 100 VUs is more realistic than instant 100 VUs.
- Thresholds: Pass/fail criteria defined before the test. If p(95) latency exceeds 500ms, k6 exits with a non-zero code, failing the CI build.
- Scenarios: Different user behaviors modeled simultaneously. A realistic e-commerce test might have 80% browsing users, 15% adding to cart, and 5% checking out.
Variants
| Tool | Scripting | Best For | Infrastructure |
|---|---|---|---|
| k6 | JavaScript/Go | Developer-friendly, CI-native | Self-hosted or cloud |
| JMeter | XML/GUI | Complex protocols, enterprise teams | Self-hosted |
| Artillery | YAML/JS | Quick configuration, Node teams | Self-hosted or cloud |
| Locust | Python | Python ecosystems, custom logic | Self-hosted |
What Works
- Test against a production-like environment: testing localhost with a single-core CPU gives meaningless results.
- Warm up the system first: caches, connection pools, and JIT compilation need time to stabilize. Run a 5-minute warm-up before measuring.
- Monitor server-side metrics during the test: correlate k6 latency spikes with database slow query logs, CPU usage, and memory pressure.
- Use realistic data distributions: if 1% of users generate 50% of load (power users), model that. Uniform random distributions rarely match reality.
- Test idempotent endpoints: non-idempotent writes (payments, inventory deductions) require special handling to avoid corrupting production data.
Common Mistakes
- Testing from a single machine: your load generator can become the bottleneck.
- Ignoring network latency: testing an API on the same datacenter underestimates real-world latency. Add realistic network delay or test from remote regions.
- Running short tests: a 30-second test tells you almost nothing. Meaningful tests run for at least 10 minutes to capture garbage collection cycles and cache warmup.
- Not validating responses: a 200ms response that returns an error page is not a success. Always assert status codes and response body content.
Troubleshooting
- Flaky tests: isolate shared state, time, and randomness. Make tests independent and deterministic; quarantine persistently flaky tests.
- High coverage but bugs in production: coverage does not guarantee correctness. Add mutation testing, property-based tests, or contract tests.
- Slow test suite: parallelize, mock slow dependencies, and avoid end-to-end tests for logic that can be unit tested.
- Tests pass locally but fail in CI: check environment differences, timezone, locale, and dependency versions. Pin tool versions.
- Debugging a failing integration test: log request/response payloads and use a dedicated test database. Reset state before each test.
Further Reading
- Official documentation: check the current reference for the framework or tool used.
- Related guides: explore the testing and api-testing guides for deeper coverage.
- Complementary patterns: review design patterns applicable to your technology stack.
- Public postmortems: study real incidents from teams that faced similar production issues.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply perform load testing on apis when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
See Also
- Integration Testing — testing service interactions
- Rate Limiting — protecting APIs under heavy traffic
- Connection Pooling — handling concurrent database connections
- Caching Strategies — reducing backend load
- API Documentation OpenAPI — documenting API contracts
Last updated: 2026-07-09
Common Production Pitfalls
- Copying the example without adapting it to real data volumes and failure modes.
- Skipping load and error-injection tests before the first production deployment.
- Hard-coding values that should be configurable per environment.
- Forgetting to add logging and monitoring at each step.
- Deploying without a rollback plan or a tested backup strategy.
- Assuming the minimal example will scale without adding caching or batching.
- Not documenting the version and configuration used in production.
- Letting the recipe sit unchanged when dependencies or scale evolve.
Frequently Asked Questions
How many virtual users do I need to simulate real traffic?
Model concurrent users, not total users. If you have 10,000 daily users but only 500 active at any moment, test with 500 VUs (plus a safety margin of 20-50%). Calculate concurrent users from analytics: concurrent_users = (daily_users * avg_session_duration_seconds) / 86400. For a site with 100,000 daily users and 5-minute average sessions: (100000 * 300) / 86400 = 347 concurrent users. Test with 500 VUs to account for peaks. For API testing, calculate RPS from peak hour traffic: if you handle 360,000 requests in the peak hour, that is 100 RPS. Use k6 scenarios with different arrival rates: scenarios: { browsing: { executor: 'ramping-arrival-rate', startRate: 10, timeUnit: '1s', stages: [{ target: 100, duration: '2m' }] } }.
What is the difference between load testing and stress testing?
Load testing validates behavior at expected traffic levels. Stress testing pushes beyond expected levels to find the breaking point and observe recovery behavior. Soak testing runs at normal load for extended periods (hours) to detect memory leaks and resource exhaustion. Spike testing suddenly increases load to verify the system handles sudden bursts. Breakpoint testing incrementally increases load until the system fails, identifying the exact failure threshold. Each test type serves a different purpose: load tests validate SLO compliance, stress tests reveal failure modes, soak tests catch long-running issues, spike tests verify autoscaling. In k6, implement each: // Stress test\nexport const options = { stages: [{ duration: '10m', target: 1000 }] };\n// Soak test\nexport const options = { stages: [{ duration: '4h', target: 200 }] };\n// Spike test\nexport const options = { stages: [{ duration: '10s', target: 500 }, { duration: '1m', target: 500 }, { duration: '10s', target: 0 }] }.
Can I run load tests in CI/CD pipelines?
Yes. k6 and Artillery are designed for this. Run nightly smoke tests (small load) and pre-release regression tests (full load) in your pipeline. In GitHub Actions: name: Load Test\non: pull_request\njobs:\n k6:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: grafana/k6-action@v0.3.1\n with:\n filename: tests/load/test.js\n flags: --quiet --thresholds. Use k6 thresholds to fail the build: thresholds: { http_req_duration: ['p(95)<500'], http_req_failed: ['rate<0.01'] }. For cost-effective CI testing, run smoke tests with 10-20 VUs for 1 minute on every PR, and full load tests with 500+ VUs only on release branches. Use k6 cloud for distributed tests: k6 cloud test.js --vus 500 --duration 10m. Cache test dependencies in CI: npm ci for k6 scripts, mvn dependency:resolve for JMeter.
Should I test production directly?
Only with extreme caution. Use synthetic transactions, read-only endpoints, and off-peak hours. Prefer staging for destructive or write-heavy tests. For production testing, use shadow traffic: mirror real requests to a test endpoint without affecting users. In k6, use the --out flag to export metrics without impacting production: k6 run --out json=results.json test.js. For read-only production tests: export default function () { const res = http.get('https://api.example.com/health'); check(res, { 'status is 200': (r) => r.status === 200 }); }. Use feature flags to isolate test traffic: route test requests to a separate backend pool. Monitor production metrics during testing: if error rate exceeds 0.5%, abort the test immediately. Use k6's --abort-on-error flag: k6 run --abort-on-error test.js. For payment systems, use sandbox endpoints that simulate the payment processor without real charges.
How do I correlate load test results with server-side metrics?
Run load tests while monitoring server-side metrics to identify bottlenecks. Use Prometheus and Grafana: # docker-compose.yml\nservices:\n prometheus:\n image: prom/prometheus\n grafana:\n image: grafana/grafana. In k6, export metrics to Prometheus: k6 run --out experimental-prometheus=http://prometheus:9090 test.js. Correlate k6 latency with database metrics: query Prometheus for pg_stat_database_tup_returned during the test window. Use distributed tracing with Jaeger: instrument the API with OpenTelemetry, then trace specific slow requests found in k6 results. In k6, add custom tags for tracing: const res = http.get('https://api.example.com/users', { tags: { test_run: 'nightly-2025-01-15' } });. Monitor JVM metrics for Java applications: jcmd <pid> GC.heap_info during the test. Track connection pool usage: SELECT count(*) FROM pg_stat_activity WHERE state = 'active' during the test. Use APM tools like Datadog or New Relic to overlay k6 metrics with server metrics in a single dashboard.
How do I handle authentication in load tests?
Handle authentication by logging in once per VU iteration and reusing tokens. For Bearer tokens: import http from 'k6/http';\nconst token = __ENV.API_TOKEN;\nexport default function () {\n const res = http.get('https://api.example.com/users', {\n headers: { Authorization: Bearer ${token} }\n });\n};. For OAuth2 login flows: export default function () {\n const loginRes = http.post('https://api.example.com/oauth/token', {\n client_id: 'test_client',\n client_secret: 'test_secret',\n grant_type: 'client_credentials'\n });\n const token = loginRes.json('access_token');\n http.get('https://api.example.com/users', {\n headers: { Authorization: Bearer ${token} }\n });\n}. For performance, cache tokens across iterations: let cachedToken = null;\nexport function setup() {\n const res = http.post('https://api.example.com/oauth/token', { ... });\n return { token: res.json('access_token') };\n}\nexport default function (data) {\n http.get('https://api.example.com/users', {\n headers: { Authorization: Bearer ${data.token} }\n });\n}. Use k6's setup() and teardown() functions for login/logout. For JWT with refresh, handle token expiration: if (Date.now() > tokenExpiry) { refreshToken(); }.
How do I test WebSocket connections with k6?
k6 supports WebSocket testing for real-time applications. Create a WebSocket connection: import ws from 'k6/ws';\nexport default function () {\n const url = 'wss://api.example.com/ws';\n ws.connect(url, {}, (socket) => {\n socket.on('open', () => {\n socket.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));\n });\n socket.on('message', (data) => {\n check(data, { 'has payload': (d) => JSON.parse(d).payload !== undefined });\n });\n socket.setInterval(() => {\n socket.send(JSON.stringify({ type: 'ping' }));\n }, 30000);\n socket.setTimeout(() => {\n socket.close();\n }, 60000);\n });\n}. Test connection stability under load: export const options = {\n vus: 100,\n duration: '5m',\n thresholds: {\n ws_sessions_opened: ['count>0'],\n ws_msgs_received: ['rate>10'],\n ws_sessions_closed: ['rate<0.1']\n }\n};. Measure message latency: socket.on('message', (data) => {\n const msg = JSON.parse(data);\n if (msg.timestamp) {\n const latency = Date.now() - msg.timestamp;\n console.log(WS latency: ${latency}ms);\n }\n});. Test reconnection logic: close connections randomly and verify the client reconnects within 5 seconds.
How do I parameterize load tests with test data?
Use CSV data files or generate test data dynamically. With k6, load CSV data: import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';\nconst users = papaparse.parse(open('./users.csv'), { header: true }).data;\nexport default function () {\n const user = users[Math.floor(Math.random() * users.length)];\n http.post('https://api.example.com/login', {\n email: user.email,\n password: user.password\n });\n}. Generate random data: import { randomString, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';\nexport default function () {\n const email = user${randomIntBetween(1, 10000)}@test.com;\n http.post('https://api.example.com/users', { email, name: randomString(8) });\n}. Use k6 execution contexts for unique data per VU: export default function () {\n const vuId = __VU;\n const iterId = __ITER;\n const email = user-${vuId}-${iterId}@test.com;\n http.post('https://api.example.com/users', { email });\n}. For JMeter, use CSV Data Set Config: <CSVDataSet filename="users.csv" variableNames="email,password" delimiter="," recycle=false/>. For large datasets, use a database: const db = sql.open('postgres', 'host=localhost dbname=testdb');\nexport default function () {\n const user = db.query('SELECT email, password FROM test_users ORDER BY RANDOM() LIMIT 1')[0];\n http.post('https://api.example.com/login', { email: user.email, password: user.password });\n}.
How do I measure percentile latencies correctly?
Percentile latencies (p50, p90, p95, p99) provide better insight than averages. A p99 of 2 seconds means 1% of users experience 2+ second delays. In k6, configure thresholds: thresholds: {\n http_req_duration: ['p(50)<200', 'p(90)<500', 'p(95)<800', 'p(99)<2000']\n}. Interpret percentiles: p50 (median) shows typical user experience, p90 shows the slow end, p99 shows tail latency. Do not use averages for latency: a few 10-second outliers can make a 200ms average misleading. Use histograms for visualization: k6 run --out json=results.json test.js && jq '.metrics.http_req_duration.values' results.json. For accurate measurements, run tests long enough: 10+ minutes for stable percentiles. Discard the first 2 minutes (warmup) from analysis. Use k6's --summary-export for machine-readable output: k6 run --summary-export=summary.json test.js. Compare percentiles across test runs to track regressions: store results in a time series database and alert when p95 increases by more than 20%.
How do I test API rate limiting under load?
Verify rate limiting behavior by sending requests above the rate limit threshold. In k6: export const options = {\n scenarios: {\n burst: {\n executor: 'constant-arrival-rate',\n rate: 200,\n timeUnit: '1s',\n duration: '1m',\n preAllocatedVUs: 300\n }\n },\n thresholds: {\n http_req_failed: ['rate<0.15']\n }\n};\nexport default function () {\n const res = http.get('https://api.example.com/api');\n check(res, {\n 'status is 200 or 429': (r) => r.status === 200 || r.status === 429,\n 'has rate limit headers': (r) => r.headers['X-RateLimit-Limit'] !== undefined\n });\n}. Verify the 429 response includes Retry-After header: check(res, {\n '429 has Retry-After': (r) => r.status !== 429 || r.headers['Retry-After'] !== undefined\n});. Test rate limit recovery: after hitting the limit, wait and verify requests succeed again: if (res.status === 429) {\n const retryAfter = parseInt(res.headers['Retry-After']);\n sleep(retryAfter + 1);\n const retryRes = http.get('https://api.example.com/api');\n check(retryRes, { 'recovered': (r) => r.status === 200 });\n}. Test per-user rate limits: use different API keys per VU. Test sliding window vs fixed window behavior: send requests at the boundary of the window.
Related Resources
Write Integration Tests
How to test multiple components working together using real databases, HTTP clients, and message queues in Python, JavaScript, and Java.
RecipeRate Limiting
How to implement API rate limiting using token bucket, sliding window, and fixed window algorithms across Python, JavaScript, and Java.
RecipeSet Up Connection Pooling for Databases and HTTP Clients
Set up connection pooling for PostgreSQL, MySQL, Redis, and HTTP clients in Python, JavaScript, and Java. Reduce latency and avoid connection exhaustion.
RecipeLoad Testing APIs with k6 and Threshold-Based Assertions
How to write and run load tests with k6 to measure API performance, validate SLOs, and identify bottlenecks before production deployment
RecipeWrite Unit Tests with Mocks and Stubs
How to isolate code under test using mock objects, stubs, and spies to replace external dependencies like databases, APIs, and file systems.