StackPractices
intermediate By Mathias Paulenko

Load Test Plan Template

Fillable load test plan template: scenarios, ramp-up profiles, pass/fail thresholds, monitoring setup, and ready-to-run k6, JMeter and Locust scripts.

Overview

Load tests fail long before the first request fires — when objectives are vague, user journeys are invented, and pass/fail thresholds get negotiated after the results are in. This template fixes that: a structured plan you fill in before running the test, covering objectives, user journeys, load profiles, success criteria, tool selection (k6, JMeter, Locust), monitoring, and reporting. Use it before a major release, a migration, or any expected traffic spike — and pair it with the Load Test Execution Plan Template when you need a day-of runbook for the test itself.


When to Use

  • Before a major release, migration, or infrastructure change (new database, cache layer, scaling rules).
  • Ahead of expected traffic spikes: Black Friday, product launches, marketing campaigns.
  • When validating capacity against SLOs or answering “can we handle 2x current peak?”.
  • For quarterly baseline runs that catch performance regressions early.
  • When a service has to pass a production readiness review and needs evidence, not anecdotes.

Don’t use this template for functional correctness (that is what integration and contract tests are for), security probing, or browser rendering performance — those need different tooling and a different document. For pure API-level load generation without a formal plan, go straight to the load testing recipe.


How to Use This Template

Fill in the sections in order: the journeys you map in §2 determine the profile in §3, and the profile shapes the thresholds in §4. Skipping ahead is how plans end up with numbers nobody can defend.

flowchart diagram: 1. Objectives
  1. Fill in objectives (§1). Pick 1-3 primary goals — a plan that tries to answer every question ends up answering none of them.
  2. Map real user journeys (§2). Pull the traffic mix from your analytics or access logs; don’t guess the percentages.
  3. Choose a load profile (§3). Match the profile to the objective: step load for bottleneck hunting, soak for memory leaks, spike for autoscaling validation.
  4. Adapt the thresholds to your SLOs (§4). The values below are sane defaults, not universal truths — a checkout flow and a metrics exporter don’t share latency budgets.
  5. Pick a tool and adapt the script (§5). Start from the example closest to your stack; the k6 load testing recipe covers thresholds and assertions in more depth.
  6. Wire up monitoring before running (§6). If you can’t see server-side CPU, memory, and database metrics during the test, the numbers you collect are anecdotes.
  7. Execute and record (§7). Fill in the report during the test, not days later — the load test report template expands it into a stakeholder-ready document.
  8. Share, fix, re-run. A plan without a scheduled re-run after fixes is a document, not a practice.

1. Test Objectives

Before touching any tooling, write down the question this test has to answer. Every downstream decision — journeys, load profile, thresholds — should trace back to one of these goals.

1.1 Goal Definition

Objective                    | Question Answered
─────────────────────────────┼──────────────────────────────────────
Baseline performance         | What is current throughput and latency?
Capacity validation          | Can we handle expected peak traffic?
Bottleneck identification    | Where does the system break first?
Scalability validation       | Does horizontal scaling work as expected?
Regression detection         | Did the latest deploy degrade performance?
Endurance / soak             | Does memory leak over 24h of load?
Spike readiness              | Can we survive a 10x traffic spike?

1.2 Scope Definition

In scope                          | Out of scope
──────────────────────────────────┼──────────────────────────────────
API endpoints under test          | Third-party API load testing
Database read/write performance   | Browser rendering performance
Cache hit/miss behavior           | Network latency simulation
Connection pool sizing            | Security / penetration testing
Rate limiting behavior            | UI / E2E testing

2. User Journey Definitions

2.1 Journey Mapping

Journey name      | Steps                                    | % of traffic | Requests/user
──────────────────┼──────────────────────────────────────────┼──────────────┼──────────────
Browse homepage   | GET / → GET /api/products                | 40%          | 2
View product      | GET / → GET /products/{id} → GET /api/reviews | 25%     | 3
Search            | GET /search?q={query} → GET /api/results | 20%          | 2
Add to cart       | View product → POST /api/cart → GET /api/cart | 10%     | 4
Checkout          | Add to cart → POST /api/checkout → POST /api/payment | 5% | 6

2.2 Test Data Requirements

Data type         | Source              | Volume needed    | Notes
──────────────────┼─────────────────────┼──────────────────┼──────────────────
Product IDs       | Production snapshot | 10000            | Use real IDs
User accounts     | Test data generator | 1000             | Pre-created accounts
Search queries    | Query log sample    | 500              | Realistic distribution
Payment tokens    | Stripe test tokens  | 100              | Use test environment
Session tokens    | Auth API            | Per-test          | Generate during setup

3. Load Profiles

3.1 Ramp-Up Strategies

Profile type    | Pattern                          | Use case
────────────────┼──────────────────────────────────┼──────────────────────────
Linear ramp     | +10 users/sec until target       | Baseline, capacity test
Step load       | 50 → 100 → 200 → 500 (hold 5m)   | Bottleneck identification
Spike           | 0 → 1000 in 10s, hold 2m         | Spike readiness, autoscaling
Soak            | 200 users for 24h                | Memory leaks, resource exhaustion
Stress          | Increase until system fails      | Find breaking point

3.2 Load Profile Configuration

Test phase      | Duration | Users | Ramp rate | Purpose
────────────────┼──────────┼───────┼───────────┼──────────────────────
Warmup          | 2 min    | 10    | 5/sec     | JIT compilation, caches
Baseline        | 10 min   | 50    | 5/sec     | Normal load baseline
Ramp-up         | 5 min    | 200   | 30/sec    | Peak traffic simulation
Peak hold       | 15 min   | 200   | -         | Sustained peak performance
Ramp-down       | 2 min    | 0     | -/sec     | Graceful shutdown
Cooldown        | 2 min    | 0     | -         | Observe recovery

4. Success Criteria

4.1 Performance Thresholds

Metric                    | Target      | Hard limit   | Failure action
──────────────────────────┼─────────────┼──────────────┼──────────────────
P50 response time         | < 100ms     | < 200ms      | Investigate
P95 response time         | < 300ms     | < 500ms      | Investigate
P99 response time         | < 500ms     | < 1000ms     | Investigate
Error rate                | < 0.1%      | < 1%         | Stop test
Throughput (req/sec)      | > 500       | > 300        | Investigate
CPU usage                 | < 70%       | < 90%        | Scale up
Memory usage              | < 75%       | < 90%        | Investigate leaks
DB connection pool usage  | < 60%       | < 80%        | Increase pool

4.2 Pass/Fail Criteria

Result   | Conditions
─────────┼──────────────────────────────────────────────────────────
PASS     | All metrics within target thresholds
PASS     | P95 within hard limit, P99 slightly over (documented)
FAIL     | Any metric exceeds hard limit
FAIL     | Error rate > 1% at any point during test
FAIL     | System crashes or becomes unresponsive
INCONCL. | Test infrastructure issues prevented valid results

5. Tool Selection

Pick the tool that matches your team’s language and your protocol needs — not the one with the most GitHub stars. All three scripts below implement the same journeys and thresholds from the sections above, so you can compare them directly.

5.1 Tool Comparison

Tool    | Language   | Protocol support        | Distributed | Scripting | Best for
────────┼────────────┼─────────────────────────┼─────────────┼───────────┼──────────────
k6      | JavaScript | HTTP, gRPC, WebSocket   | Yes (k6 cloud) | JS/ES6  | API testing, CI/CD
JMeter  | Java       | HTTP, JDBC, JMS, SMTP   | Yes (master/slave) | XML/Groovy | Enterprise, protocol variety
Locust  | Python     | HTTP, custom            | Yes (master/worker) | Python  | Custom protocols, flexibility
Artillery | Node.js  | HTTP, WebSocket, Socket.io | Yes (AWS) | YAML/JS | Quick API tests
Gatling  | Scala     | HTTP, WebSocket         | Yes | Scala DSL | High throughput, detailed reports

5.2 k6 Script Example

import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const errorRate = new Rate('errors');
const responseTime = new Trend('response_time');

export const options = {
  stages: [
    { duration: '2m', target: 10 },   // warmup
    { duration: '10m', target: 50 },  // baseline
    { duration: '5m', target: 200 },  // ramp-up
    { duration: '15m', target: 200 }, // peak hold
    { duration: '2m', target: 0 },    // ramp-down
  ],
  thresholds: {
    http_req_duration: ['p(95)<300', 'p(99)<500'],
    http_req_failed: ['rate<0.01'],
    errors: ['rate<0.001'],
  },
};

export default function () {
  // Journey: Browse homepage
  group('Browse homepage', function () {
    const homeRes = http.get('https://staging.example.com/');
    check(homeRes, {
      'homepage status 200': (r) => r.status === 200,
      'homepage LCP < 2.5s': (r) => r.timings.waiting < 2500,
    });
    errorRate.add(homeRes.status !== 200);
    responseTime.add(homeRes.timings.duration);
    sleep(1);
  });

  // Journey: View product
  group('View product', function () {
    const productId = Math.floor(Math.random() * 10000) + 1;
    const productRes = http.get(`https://staging.example.com/api/products/${productId}`);
    check(productRes, {
      'product status 200': (r) => r.status === 200,
      'product has name': (r) => r.json('name') !== undefined,
    });
    errorRate.add(productRes.status !== 200);
    responseTime.add(productRes.timings.duration);
    sleep(2);
  });

  // Journey: Search
  group('Search', function () {
    const queries = ['laptop', 'phone', 'headphones', 'keyboard', 'mouse'];
    const query = queries[Math.floor(Math.random() * queries.length)];
    const searchRes = http.get(`https://staging.example.com/api/search?q=${query}`);
    check(searchRes, {
      'search status 200': (r) => r.status === 200,
      'search returns results': (r) => r.json('results').length > 0,
    });
    errorRate.add(searchRes.status !== 200);
    responseTime.add(searchRes.timings.duration);
    sleep(1.5);
  });
}

5.3 JMeter Test Plan (XML)

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Load Test Plan">
      <stringProp name="TestPlan.comments">Peak load test — 200 users</stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
    </TestPlan>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Users">
        <intProp name="ThreadGroup.num_threads">200</intProp>
        <intProp name="ThreadGroup.ramp_time">300</intProp>
        <boolProp name="ThreadGroup.scheduler">true</boolProp>
        <stringProp name="ThreadGroup.duration">1800</stringProp>
      </ThreadGroup>
      <hashTree>
        <HTTPSamplerProxy testname="Homepage">
          <stringProp name="HTTPSampler.domain">staging.example.com</stringProp>
          <stringProp name="HTTPSampler.path">/</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
        </HTTPSamplerProxy>
        <hashTree>
          <ResponseAssertion testname="Status 200">
            <collectionProp name="Asserion.test_strings">
              <stringProp>200</stringProp>
            </collectionProp>
          </ResponseAssertion>
        </hashTree>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

5.4 Locust Script (Python)

import random

from locust import HttpUser, task, between

class WebsiteUser(HttpUser):
    wait_time = between(1, 3)
    host = "https://staging.example.com"

    @task(40)
    def browse_homepage(self):
        with self.client.get("/", catch_response=True) as response:
            if response.status_code != 200:
                response.failure(f"Homepage failed: {response.status_code}")

    @task(25)
    def view_product(self):
        product_id = random.randint(1, 10000)
        with self.client.get(f"/api/products/{product_id}", catch_response=True) as response:
            if response.status_code != 200:
                response.failure(f"Product failed: {response.status_code}")
            elif response.elapsed.total_seconds() > 0.5:
                response.failure("Product too slow")

    @task(20)
    def search(self):
        queries = ['laptop', 'phone', 'headphones', 'keyboard', 'mouse']
        query = random.choice(queries)
        with self.client.get(f"/api/search?q={query}", catch_response=True) as response:
            if response.status_code != 200:
                response.failure(f"Search failed: {response.status_code}")

    @task(10)
    def add_to_cart(self):
        product_id = random.randint(1, 10000)
        with self.client.post("/api/cart",
            json={"product_id": product_id, "quantity": 1},
            catch_response=True
        ) as response:
            if response.status_code != 201:
                response.failure(f"Cart failed: {response.status_code}")

    @task(5)
    def checkout(self):
        with self.client.post("/api/checkout",
            json={"payment_token": "test_token", "address_id": 1},
            catch_response=True
        ) as response:
            if response.status_code not in [200, 201]:
                response.failure(f"Checkout failed: {response.status_code}")

6. Monitoring Setup

6.1 Metrics to Collect

Layer        | Metric                    | Tool
─────────────┼───────────────────────────┼──────────────────────────
Application  | Response time percentiles | k6/JMeter/Locust built-in
Application  | Error rate                | k6/JMeter/Locust built-in
Application  | Throughput (req/sec)      | k6/JMeter/Locust built-in
Server       | CPU usage                 | Prometheus + node_exporter
Server       | Memory usage              | Prometheus + node_exporter
Server       | Disk I/O                  | Prometheus + node_exporter
Database     | Active connections        | pg_stat_activity / SHOW PROCESSLIST
Database     | Query latency             | pg_stat_statements / slow query log
Database     | Lock waits                | pg_locks / information_schema
Cache        | Hit rate                  | Redis INFO / Memcached stats
Cache        | Evictions                 | Redis INFO / Memcached stats
Network      | Bandwidth                 | iftop / CloudWatch

6.2 Grafana Dashboard Panels

Panel name              | Query (Prometheus)
────────────────────────┼──────────────────────────────────────────
Request rate            | rate(http_requests_total[1m])
P95 latency             | histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
Error rate              | rate(http_requests_total{status=~"5.."}[1m]) / rate(http_requests_total[1m])
CPU usage               | 100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)
Memory usage            | node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes
DB connections          | pg_stat_activity_count
Cache hit rate          | redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total)

7. Result Analysis and Reporting

A load test nobody reads about is a load test that never happened. The report below is deliberately short — one page of configuration, results, bottlenecks, and recommendations that a stakeholder can scan in two minutes. Numbers without a verdict invite re-interpretation, so always end with explicit recommendations and a scheduled re-run.

7.1 Report Template

## Load Test Report — {Service Name}

**Date:** 2026-10-14
**Tester:** {Name}
**Environment:** Staging (replica of production)
**Tool:** k6 0.50.0

### Test Configuration

| Parameter       | Value          |
|-----------------|----------------|
| Max users       | 200            |
| Duration        | 34 min         |
| Ramp-up         | 5 min to 200   |
| Peak hold       | 15 min at 200  |

### Results Summary

| Metric              | Target  | Actual  | Status |
|---------------------|---------|---------|--------|
| P50 response time   | < 100ms | 45ms    | PASS   |
| P95 response time   | < 300ms | 180ms   | PASS   |
| P99 response time   | < 500ms | 420ms   | PASS   |
| Error rate          | < 0.1%  | 0.02%   | PASS   |
| Throughput          | > 500   | 680 rps | PASS   |
| Peak CPU            | < 70%   | 65%     | PASS   |
| Peak memory         | < 75%   | 68%     | PASS   |

### Bottlenecks Identified

1. Database connection pool saturated at 180 users (pool size: 20)
   - Fix: Increase pool size to 40 or add PgBouncer

2. Search API P95 spikes at 200 users (180ms → 350ms)
   - Fix: Add Elasticsearch for search instead of PostgreSQL ILIKE

3. Redis eviction rate increases at peak (0 → 15/sec)
   - Fix: Increase Redis maxmemory or add cache warming

### Recommendations

1. Increase DB connection pool to 40 before next traffic event
2. Migrate search to Elasticsearch (tracked in JIRA-1234)
3. Add autoscaling rule: scale at 70% CPU, not 80%
4. Re-run load test after fixes to verify improvements

Common Mistakes

Declaring pass/fail after the test

Thresholds negotiated after seeing results aren’t thresholds — they’re rationalizations. Write the pass/fail criteria in the plan before anyone runs anything, and treat “we’ll know a good result when we see it” as a red flag in review.

Testing a scaled-down environment

A staging box at a tenth of production size tells you almost nothing about production capacity. If the test environment can’t mirror production specs, say so in the plan and mark the results as directional, not conclusive.

Replaying the same request data

Sending the same product ID or search query on every request lets caches absorb the load and inflates your numbers. Rotate through real IDs, real queries from your logs, and a realistic distribution — that’s what §2.2 is for.

No think time between requests

Real users pause. Firing requests back-to-back measures a denial-of-service attack, not user traffic, and produces throughput numbers your system will never see. Keep the 1-5 second sleeps in the scripts.

Co-locating the load generator

Running k6 or Locust on the same host or network segment as the system under test hides network latency and steals CPU from the very thing you’re measuring. Generate load from a separate machine — ideally in the same region your users come from.

See Also

Frequently Asked Questions

What is the difference between load testing and stress testing?

Load testing validates that the system handles expected production traffic within performance targets. You simulate realistic user loads (e.g., 200 concurrent users) and verify response times, error rates, and resource usage stay within thresholds. Stress testing pushes the system beyond expected capacity to find its breaking point — you keep increasing load until errors spike or the system crashes. Load testing answers "can we handle our expected traffic?" Stress testing answers "what happens when we exceed it?" Run load tests before every major release. Run stress tests quarterly or before major traffic events (Black Friday, product launches) to understand your system's limits and autoscaling behavior.

How many concurrent users should I test with?

Start with your current peak traffic — check analytics for the highest concurrent user count in the last 30 days. Test at 1x (current peak), 2x (expected growth), and 5x (stress scenario). If you don't have analytics data, estimate from request volume: if you get 100,000 requests per hour, that's roughly 28 req/sec, which translates to about 50-100 concurrent users depending on journey length. For a new service with no traffic data, start with 100 users and scale up. Always test in an environment that mirrors production — testing in a scaled-down staging environment gives misleading results.

Which load testing tool should I choose?

For API testing with CI/CD integration, k6 is the best choice — it scripts in JavaScript, has built-in thresholds for pass/fail, and integrates with GitHub Actions. For enterprise environments with diverse protocols (JMS, SMTP, JDBC), JMeter has the broadest protocol support and a GUI for non-developers. For custom protocols or cases where you need full control in code, Locust (Python) is the most flexible option. For quick HTTP API tests, Artillery (Node.js) has the simplest YAML-based configuration. Consider your team's language expertise — a Python team will be more comfortable with Locust, a JavaScript team with k6. All four tools can distribute load across multiple workers when a single generator isn't enough.

How do I simulate realistic user behavior?

Map real user journeys from your analytics — what percentage of users browse, search, view products, checkout? Assign weights to each journey in your test script (k6: group() calls or exec weights, Locust: @task(40)). Add think time between requests (1-5 seconds) — real users don't send requests back-to-back. Use realistic test data — real product IDs, real search queries from your query log, not random strings. Vary the data per request so caches don't skew results. Include authentication — most production traffic is authenticated. Test the full journey, not just individual endpoints — the system behavior under a sequence of requests differs from isolated calls. If you've got session management, test session creation and expiry.

What should I do if the load test fails?

First, determine which threshold was exceeded. If error rate is high, check server logs for exceptions and database logs for connection issues. If response times are high, check CPU and memory usage during the test — if CPU hit 90%, the server is under-provisioned. If database connections are exhausted, increase the pool size. If the system crashed, check for memory leaks (run a soak test) or resource exhaustion. Document the failure in the test report with the specific metric, the actual value, and the root cause. Fix the issue and run the test again under the same profile. Whatever you do, don't lower the thresholds to turn a failure into a pass — that defeats the purpose. If the failure is due to test infrastructure (not the application), mark the result as inconclusive and re-run with fixed infrastructure.