Load Testing: How to Find Your App's Breaking Point

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 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% ✓ 15000
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%   ✓ 01500
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:

  1. Error rate — Should be 0% at normal load, rising only as you exceed capacity
  2. p95 response time — Should stay under your threshold (500ms is common)
  3. Where response time starts degrading — That's your current capacity
  4. 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.

Read more