Load Testing: How to Find Your App's Breaking Point
Your app works fine with 10 users. You launch a Product Hunt campaign, 500 people hit it simultaneously, and it falls over. Load testing finds that breaking point before your users do. This guide covers what load testing is, which tools to use, what metrics matter, and how to run your first test today.
Key Takeaways
Load testing measures how your system behaves under expected and peak traffic. It answers: how many concurrent users can we handle before response times degrade? What happens when we exceed that?
Virtual users (VUs) are the core concept. Each VU simulates one user's session — navigating pages, submitting forms, waiting between actions. 100 VUs means 100 simulated simultaneous users.
Throughput and response time are the key metrics. Throughput = requests per second. Response time = how long each request takes. You want high throughput with low, consistent response times.
Test realistic scenarios, not just homepage hits. A test that only hits your homepage tells you nothing about checkout performance. Model what real users actually do — browse, add to cart, pay.
Start with a baseline, then increase load. Find your current performance baseline at normal load. Then ramp up until things break. That breakpoint is your capacity limit.
What Is Load Testing?
Load testing applies simulated user traffic to your application to measure how it performs under expected and peak conditions.
The goal isn't just "does it work?" — it's "how does it work under pressure?" Specifically:
- Response times at normal traffic levels
- Response times at 2x, 5x, 10x normal traffic
- The point at which response times become unacceptable
- The point at which the system fails completely
Without load testing, you discover capacity limits in production — usually during a product launch, a viral moment, or a sale.
Load Testing vs Performance Testing vs Stress Testing
These terms are often used interchangeably, but they have distinct meanings:
| Test Type | Goal | Traffic Level |
|---|---|---|
| Load test | Measure behavior at expected traffic | Normal to 2-3x peak |
| Stress test | Find the breaking point | Ramp up until failure |
| Spike test | Test sudden traffic bursts | Instant jump to 10x+ |
| Soak test | Find memory leaks and degradation | Normal load for hours |
| Scalability test | Measure how capacity scales with resources | Incremental increases |
Key Metrics
Response Time
How long it takes the server to respond to a request. Measured in milliseconds.
- p50 (median): 50% of requests complete within this time
- p95: 95% of requests complete within this time — the more important metric
- p99: 99% of requests — this is where extreme slowness lives
Target thresholds (approximate):
- Under 200ms: Excellent
- 200-500ms: Acceptable
- 500ms-1s: Noticeable to users
- 1-3s: Users start abandoning
- 3s+: Most users leave
The p95 and p99 matter more than the median. Your median might be 150ms, but if your p99 is 8 seconds, 1% of users are having a terrible experience — which, at scale, is a lot of people.
Throughput
Requests per second (RPS) your system can handle. Higher throughput = more capacity.
Error Rate
Percentage of requests that return errors (5xx responses, timeouts, connection refused). During a load test, you expect 0% errors at normal traffic. An error rate above 1% signals a problem.
Concurrent Users / Virtual Users (VUs)
How many simultaneous users are active at a given moment. This is what you increase during a load test to find your limit.
Apdex Score
Application Performance Index — a standard score from 0 to 1:
- 1.0 = Everyone happy
- 0.7 = Most users satisfied
- 0.5 = Problem, investigate
- Below 0.5 = Users are leaving
Apdex = (satisfied + 0.5 * tolerating) / total requests
Where: satisfied = under 500ms, tolerating = 500ms-2s, frustrated = over 2s
Load Testing Tools
k6 (Recommended for most teams)
k6 is an open-source load testing tool with JavaScript scripting. It's developer-friendly, runs from the command line, and integrates well with CI/CD.
// basic-load-test.js
import http from 'k6/http'
import { check, sleep } from 'k6'
export const options = {
vus: 50, // 50 virtual users
duration: '30s', // Run for 30 seconds
}
export default function () {
const res = http.get('https://test.example.com/')
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
})
sleep(1) // Wait 1 second between requests (simulate user think time)
}
Run it:
k6 run basic-load-test.js
Output:
✓ status is 200
✓ response time < 500ms
checks.........................: 100.00% ✓ 1500 ✗ 0
data_received..................: 2.1 MB 70 kB/s
data_sent......................: 130 kB 4.3 kB/s
http_req_duration..............: avg=43ms min=12ms med=38ms max=342ms p(90)=89ms p(95)=112ms
http_req_failed................: 0.00% ✓ 0 ✗ 1500
http_reqs......................: 1500 50/s
Locust (Python)
Locust is a Python-based load testing tool. Good if your team knows Python, or if you need complex behavior that's easier to express in Python.
# locustfile.py
from locust import HttpUser, task, between
class WebsiteUser(HttpUser):
wait_time = between(1, 3) # Wait 1-3 seconds between tasks
@task(3) # Weight 3 — happens 3x more often
def view_products(self):
self.client.get('/products')
@task(1)
def checkout(self):
self.client.post('/cart', json={'product_id': 1})
self.client.get('/checkout')
Run:
locust -f locustfile.py --headless -u 100 -r 10 --run-time 60s --host https://example.com
Locust also has a web UI at localhost:8089 for interactive testing.
Apache JMeter
JMeter is the enterprise-grade option. GUI-based test builder, extensive reporting, large ecosystem.
Best for: teams that need a GUI, enterprise environments, complex test plans with many scenarios.
Drawback: XML-based test files are hard to version control, and the GUI is complex to learn.
Gatling
Scala/Java-based load testing. Excellent HTML reports, good CI/CD integration, code-first approach.
Best for: Java shops or teams comfortable with Scala.
Artillery
Node.js-based, YAML configuration, cloud-native features. Good for API testing.
# artillery-config.yml
config:
target: 'https://api.example.com'
phases:
- duration: 60
arrivalRate: 10
name: Warm up
- duration: 120
arrivalRate: 50
name: Peak load
scenarios:
- name: Browse products
flow:
- get:
url: '/products'
- think: 2
- get:
url: '/products/1'
Building a Realistic Load Test
The most common mistake is testing only your homepage. Real users don't just visit one URL — they navigate through multiple pages, submit forms, and wait between actions.
Step 1: Define Scenarios
Map your most common user journeys:
Scenario: E-commerce browse-to-buy (60% of users)
1. Visit homepage
2. Search for product (2-3 second pause)
3. View product page
4. Add to cart
5. Proceed to checkout
6. Enter shipping details
7. Place order
Scenario: Returning customer (30% of users)
1. Visit homepage
2. Log in
3. View account dashboard
4. Check order history
Scenario: Bounce (10% of users)
1. Visit homepage
2. Leave
Step 2: Write the k6 Script
// ecommerce-load-test.js
import http from 'k6/http'
import { check, group, sleep } from 'k6'
import { Rate, Trend } from 'k6/metrics'
const errorRate = new Rate('errors')
const checkoutDuration = new Trend('checkout_duration')
export const options = {
stages: [
{ duration: '2m', target: 20 }, // Ramp up to 20 VUs
{ duration: '5m', target: 20 }, // Stay at 20 VUs
{ duration: '2m', target: 50 }, // Ramp up to 50 VUs
{ duration: '5m', target: 50 }, // Stay at 50 VUs
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
errors: ['rate<0.01'], // Error rate under 1%
},
}
export default function () {
group('Homepage', () => {
const res = http.get('https://shop.example.com/')
check(res, { 'homepage loaded': (r) => r.status === 200 })
errorRate.add(res.status !== 200)
sleep(2)
})
group('Product search', () => {
const res = http.get('https://shop.example.com/search?q=shoes')
check(res, { 'search returned results': (r) => r.status === 200 })
sleep(1)
})
group('Add to cart', () => {
const res = http.post('https://shop.example.com/api/cart',
JSON.stringify({ product_id: 123, quantity: 1 }),
{ headers: { 'Content-Type': 'application/json' } }
)
check(res, { 'added to cart': (r) => r.status === 200 })
sleep(1)
})
group('Checkout', () => {
const start = Date.now()
const res = http.get('https://shop.example.com/checkout')
checkoutDuration.add(Date.now() - start)
check(res, { 'checkout loaded': (r) => r.status === 200 })
sleep(3)
})
}
Step 3: Run with Increasing Load
# Start small to verify the test works
k6 run --vus 5 --duration 30s ecommerce-load-test.js
<span class="hljs-comment"># Run the full ramping test
k6 run ecommerce-load-test.js
Step 4: Interpret Results
Look at:
- Error rate — Should be 0% at normal load, rising only as you exceed capacity
- p95 response time — Should stay under your threshold (500ms is common)
- Where response time starts degrading — That's your current capacity
- What breaks first — Database? API? CDN? Memory?
Common Load Testing Mistakes
Testing Against Production
Don't run load tests against your production database. You'll:
- Create thousands of test orders
- Accidentally email real users
- Potentially take down production
Use a staging environment that mirrors production, or use k6's --env to point at a test environment.
Ignoring Think Time
Users don't send 1,000 requests per second to your homepage. They click, read, pause, then click again.
// Wrong: Hammer the server with no pauses
export default function() {
http.get('/page1')
http.get('/page2')
http.get('/page3')
}
// Right: Simulate realistic user behavior
export default function() {
http.get('/page1')
sleep(2) // User reads the page
http.get('/page2')
sleep(3) // User fills out a form
http.post('/submit', data)
sleep(1)
}
Testing Only GET Endpoints
POSTs, PUTs, and DELETEs are often more expensive than GETs — they write to databases, send emails, or trigger background jobs. Make sure your load test includes write operations.
Not Setting Thresholds
Without thresholds, a load test just generates numbers. Set pass/fail thresholds upfront so CI/CD can fail the build if performance regresses:
export const options = {
thresholds: {
http_req_duration: ['p(95)<500', 'p(99)<1000'],
http_req_failed: ['rate<0.01'],
}
}
Finding Bottlenecks
When load testing reveals problems, the fix depends on where the bottleneck is:
Database Bottleneck
Symptoms: Query times grow with load, CPU on database server spikes
Fixes:
- Add indexes on frequently queried columns
- Add database read replicas for read-heavy workloads
- Implement connection pooling (PgBouncer for Postgres)
- Cache frequently-read data in Redis
Application Server Bottleneck
Symptoms: CPU on app servers spikes, response times grow proportionally with VUs
Fixes:
- Add more app server instances (horizontal scaling)
- Optimize slow code paths (use profiler to find hot spots)
- Add caching at the application layer
External API Bottleneck
Symptoms: Specific endpoints slow down, others remain fast; third-party errors in logs
Fixes:
- Implement retry logic with backoff
- Cache external API responses
- Add circuit breaker to fail fast when external service is slow
Memory Leak
Symptoms: Performance degrades over time during a soak test; memory grows steadily
Fixes:
- Profile memory usage with language-specific tools
- Look for unclosed connections, event listeners, or growing caches
CI/CD Integration
Add load testing to your pipeline to catch performance regressions before they reach production:
# .github/workflows/load-test.yml
name: Load Test
on:
push:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run k6 load test
uses: grafana/k6-action@v0.3.1
with:
filename: load-tests/smoke.js
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: k6-results
path: results.json
Use a smoke load test in CI (low VUs, short duration) to catch obvious regressions, and run full load tests manually or on a schedule against staging.
Quick Start: Your First Load Test in 5 Minutes
# Install k6
brew install k6 <span class="hljs-comment"># macOS
<span class="hljs-comment"># or: https://k6.io/docs/getting-started/installation/
<span class="hljs-comment"># Create test file
<span class="hljs-built_in">cat > my-first-test.js << <span class="hljs-string">'EOF'
import http from <span class="hljs-string">'k6/http'
import { check, <span class="hljs-built_in">sleep } from <span class="hljs-string">'k6'
<span class="hljs-built_in">export const options = {
vus: 10,
duration: <span class="hljs-string">'30s',
}
<span class="hljs-built_in">export default <span class="hljs-function">function () {
const res = http.get(<span class="hljs-string">'https://your-app.com/')
check(res, { <span class="hljs-string">'status 200': (r) => r.status === 200 })
<span class="hljs-built_in">sleep(1)
}
EOF
<span class="hljs-comment"># Run it
k6 run my-first-test.js
Replace https://your-app.com/ with your staging URL. You'll see response times and throughput within 30 seconds.
Start small, measure, find the limit. Then you know exactly what you're working with before launch day.
Want to test your app's performance without writing load test scripts? HelpMeTest automates end-to-end testing so your team can focus on features, not test maintenance.