
Most teams treat performance as something to worry about later -- after the feature ships, after the sprint ends, after production is already on fire. Functional tests run on every pull request, but load tests? Those happen quarterly at best, usually in a panic after a major outage. This is backwards. Performance regressions are bugs, and like all bugs, they are cheapest to fix when caught early. Integrating load testing into your CI/CD pipeline is how you stop treating performance as an afterthought and start treating it as a first-class quality gate.
This guide walks you through the entire process: why it matters, how to structure tests for automation, practical examples for major CI platforms, and the pitfalls that trip up most teams.
Why Load Test in CI/CD?
The shift-left philosophy argues that testing should happen as early as possible in the development lifecycle. Unit tests shifted left decades ago. Integration tests followed. Performance testing is the next frontier, and arguably the most impactful one left.
Here is what happens without automated performance testing in your pipeline:
- A developer optimizes a database query for readability but accidentally removes an index hint. The change passes code review because nobody benchmarks during review. The query goes from 5ms to 800ms under load. Production slows to a crawl the following Tuesday.
- A new middleware layer adds 40ms of latency to every request. Individually imperceptible, but compounding across chained microservice calls, it pushes p95 response times past your SLA.
- An ORM upgrade changes how connection pooling works. Everything looks fine in development with a single user. Under 200 concurrent users, the pool exhausts and requests start queuing.
All three of these are real-world scenarios, and all three would have been caught by a load test running in CI. The "it worked in staging" surprise is almost always a concurrency problem, and concurrency problems only surface under load.
When load tests run automatically alongside your functional test suite, performance becomes a continuous feedback loop rather than a periodic audit. Developers learn immediately when their changes cause regressions. Over time, the entire team develops better instincts about performance because they see the impact of every change.
The Performance Gate Concept
A performance gate works exactly like a quality gate in CI/CD: you define pass/fail criteria, and the pipeline stops if those criteria are not met. Instead of checking whether tests pass or code coverage meets a threshold, a performance gate checks whether your application meets its performance targets under load.
Typical performance gate criteria include:
| Metric | Example Threshold | Why It Matters |
|---|---|---|
| p95 Response Time | < 500ms | Ensures the vast majority of users have an acceptable experience |
| Error Rate | < 1% | Catches regressions that cause failures under concurrency |
| Throughput | > 100 RPS | Confirms the system can handle expected request volume |
| p99 Response Time | < 2000ms | Guards against extreme tail latency |
The key principle is that these thresholds must be concrete and automated. A load test that requires someone to manually read a report and decide whether things look "okay" is not a gate -- it is a suggestion. True performance gates fail the build automatically when criteria are violated, just like a failing unit test.
Start with thresholds based on your current baseline performance, then tighten them over time as you optimize. This approach, sometimes called a performance ratchet, ensures you never regress and gradually improve.
Choosing the Right Test for Your Pipeline
Not every load test belongs at every stage of your pipeline. Running a 30-minute stress test on every pull request would grind your development velocity to a halt. The solution is to layer your tests by purpose and frequency.
On Every PR: Smoke Load Test
A smoke load test is the lightest possible performance check. It runs 10 to 20 virtual users for 2 to 3 minutes against your core endpoints. The goal is not to find your application's breaking point -- it is to catch obvious regressions quickly.
Think of it as the performance equivalent of a unit test: fast, focused, and designed to give you a clear pass or fail. A smoke load test can typically complete in under 5 minutes including setup and teardown, which is fast enough to include in PR checks without annoying developers.
What a smoke test catches:
- Endpoints that suddenly return errors under minimal concurrency
- Response time regressions larger than 2-3x the baseline
- New endpoints that are dramatically slower than expected
- Broken connection handling or resource cleanup
What it will not catch: subtle degradation that only appears at scale, memory leaks, connection pool exhaustion. Those require longer, heavier tests.
Nightly: Full Load Test
A nightly load test runs on a schedule -- typically against your staging environment after the day's merges have been deployed. This test uses 200 or more virtual users, runs for 15 to 30 minutes, and exercises a broader set of endpoints with realistic traffic patterns.
The nightly test is your comprehensive regression check. It has enough time and concurrency to find problems that smoke tests miss: gradual response time creep, connection pool limits, database query plans that degrade with more concurrent sessions.
Because it runs nightly rather than on every commit, it does not block individual developers. Instead, it provides a daily performance health report for the entire team. If the nightly test starts failing, you know the regression was introduced in the previous day's work, which narrows the investigation dramatically.
Pre-Release: Stress Test
Before a production deployment, run a stress test that pushes beyond your expected traffic levels. This test answers the question: "If this release goes out and traffic spikes, will we survive?"
Stress tests typically ramp users up until the system breaks or performance degrades unacceptably. They may run for 30 to 60 minutes and include spike patterns that simulate sudden traffic surges. Run these manually or automatically on release branches.
For a detailed comparison of these test types, see our load testing vs stress testing breakdown.
Writing a CI-Friendly Load Test
A load test designed for CI/CD needs to be deterministic, self-contained, and fast. Here is a Locust script built specifically for pipeline automation:
from locust import HttpUser, task, between
class CISmokeTest(HttpUser):
wait_time = between(0.5, 1)
@task(5)
def health_check(self):
with self.client.get("/api/health", catch_response=True) as r:
if r.elapsed.total_seconds() > 1.0:
r.failure("Health check too slow")
@task(3)
def homepage(self):
self.client.get("/")
@task(2)
def api_endpoint(self):
self.client.get("/api/products")
Several things make this script CI-friendly:
- Short wait times:
between(0.5, 1)keeps the test moving quickly. In CI, you want to compress the test duration without reducing the number of requests. - Response validation: The
catch_response=Truepattern lets you define custom failure conditions. The health check endpoint is explicitly failed if it takes longer than one second, even if it returns a 200 status code. - Weighted tasks: The
@task(N)decorator controls how frequently each task runs. The health check runs most often (weight 5), followed by the homepage (weight 3), and the API endpoint (weight 2). This should mirror your real traffic distribution. - No external dependencies: The script does not depend on CSV files, environment-specific configurations, or databases. Everything needed to run is in the file itself.
When running this on LoadForge, you upload the Locustfile and configure the user count, duration, and pass/fail thresholds through the platform. LoadForge handles distributed execution across multiple regions and returns structured results that your CI system can evaluate.
Integration with Popular CI/CD Platforms
GitHub Actions
The following GitHub Actions workflow triggers a LoadForge test on every push to main, waits for it to complete, and fails the workflow if the performance gate is not met:
name: Load Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
load-test:
runs-on: ubuntu-latest
steps:
- name: Trigger LoadForge Test
id: loadforge
run: |
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.LOADFORGE_API_KEY }}" \
-H "Content-Type: application/json" \
-d '{"test_id": "${{ vars.LOADFORGE_TEST_ID }}"}' \
https://loadforge.com/api/v1/tests/run)
RUN_ID=$(echo $RESPONSE | jq -r '.run_id')
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
- name: Wait for Results
run: |
while true; do
STATUS=$(curl -s \
-H "Authorization: Bearer ${{ secrets.LOADFORGE_API_KEY }}" \
https://loadforge.com/api/v1/runs/${{ steps.loadforge.outputs.run_id }} \
| jq -r '.status')
if [ "$STATUS" = "completed" ]; then break; fi
if [ "$STATUS" = "failed" ]; then exit 1; fi
sleep 10
done
- name: Check Performance Gate
run: |
RESULTS=$(curl -s \
-H "Authorization: Bearer ${{ secrets.LOADFORGE_API_KEY }}" \
https://loadforge.com/api/v1/runs/${{ steps.loadforge.outputs.run_id }}/results)
P95=$(echo $RESULTS | jq '.p95_response_time')
ERROR_RATE=$(echo $RESULTS | jq '.error_rate')
echo "p95: ${P95}ms, Error rate: ${ERROR_RATE}%"
if (( $(echo "$P95 > 500" | bc -l) )); then
echo "FAIL: p95 response time ${P95}ms exceeds 500ms threshold"
exit 1
fi
if (( $(echo "$ERROR_RATE > 1.0" | bc -l) )); then
echo "FAIL: Error rate ${ERROR_RATE}% exceeds 1% threshold"
exit 1
fi
The workflow stores your API key as a secret and the test configuration ID as a variable. This keeps credentials out of your codebase while making it easy to update the target test.
GitLab CI
The equivalent configuration for GitLab CI follows the same pattern:
load_test:
stage: test
image: curlimages/curl:latest
script:
- |
RESPONSE=$(curl -s -X POST \
-H "Authorization: Bearer ${LOADFORGE_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"test_id\": \"${LOADFORGE_TEST_ID}\"}" \
https://loadforge.com/api/v1/tests/run)
RUN_ID=$(echo $RESPONSE | jq -r '.run_id')
while true; do
STATUS=$(curl -s \
-H "Authorization: Bearer ${LOADFORGE_API_KEY}" \
https://loadforge.com/api/v1/runs/${RUN_ID} \
| jq -r '.status')
if [ "$STATUS" = "completed" ]; then break; fi
if [ "$STATUS" = "failed" ]; then exit 1; fi
sleep 10
done
RESULTS=$(curl -s \
-H "Authorization: Bearer ${LOADFORGE_API_KEY}" \
https://loadforge.com/api/v1/runs/${RUN_ID}/results)
P95=$(echo $RESULTS | jq '.p95_response_time')
ERROR_RATE=$(echo $RESULTS | jq '.error_rate')
echo "p95: ${P95}ms, Error rate: ${ERROR_RATE}%"
if [ $(echo "$P95 > 500" | bc) -eq 1 ]; then
echo "Performance gate failed: p95 too high"
exit 1
fi
only:
- main
- merge_requests
General Approach
The pattern above works with any CI/CD system that can execute shell commands: Jenkins, CircleCI, Azure DevOps, Bitbucket Pipelines, and others. The core logic is always the same:
- Trigger the load test via API
- Poll for completion
- Evaluate results against your thresholds
- Fail the build if thresholds are exceeded
If your CI system supports custom plugins or orbs, you can wrap this logic into a reusable component that your entire organization shares.
Setting Performance Budgets
A performance budget is the maximum amount of performance degradation you are willing to accept. Setting meaningful budgets requires a baseline -- you need to know where you stand before you can define where the line is.
Start by running your load test suite several times against your current production-like environment and recording the results. Average the metrics across runs to account for natural variance. Then set your thresholds at a margin above the baseline:
| Metric | Current Baseline | Budget (Threshold) | Rationale |
|---|---|---|---|
| p95 Response Time | 320ms | 500ms | ~50% headroom for natural variance |
| Error Rate | 0.1% | 1.0% | Order-of-magnitude buffer |
| Throughput | 450 RPS | 350 RPS | Allow minor dips, flag major drops |
Over time, as you optimize your application, re-baseline and tighten the budgets. If your p95 drops to 200ms after an optimization pass, lower the threshold to 350ms. This ratchet effect ensures you preserve gains and never silently regress back to previous performance levels.
Avoid the temptation to set budgets based on aspirational goals rather than measured data. A threshold that causes the build to fail 30% of the time due to environmental noise is worse than useless -- developers will learn to ignore it or disable the gate entirely.
Handling Test Environments
The environment you test against determines the validity of your results. There are two primary options, each with tradeoffs.
Testing against a dedicated staging environment is the most common approach. Staging should mirror production as closely as possible: same instance types, same database engine and version, same caching layer, and -- critically -- a similar volume of data. A load test against an empty database will produce wildly optimistic results because queries that scan thousands of rows in production complete instantly against ten rows in staging.
Key requirements for a reliable staging environment:
- Isolated: No other tests or processes running simultaneously. Shared staging environments are a leading cause of flaky performance tests.
- Production-sized data: Either use anonymized production data or generate synthetic data at production scale. Pay particular attention to table sizes, index cardinality, and cache warm-up state.
- Same infrastructure topology: If production uses a load balancer, CDN, and read replicas, staging should too. Testing a single server when production runs behind a cluster tells you little about real-world behavior.
- Consistent state: Reset the environment to a known state before each test run. Database migrations, accumulated log files, and stale caches can all affect results.
Testing against production is sometimes necessary for realistic results, especially for applications with complex infrastructure that is difficult to replicate. If you go this route, use synthetic test accounts, run during low-traffic windows, and keep the load modest. Production load testing is a supplement to staging tests, not a replacement.
Common Pitfalls
Even teams that commit to CI/CD load testing run into recurring problems. Here are the most common and how to avoid them.
Running load tests against shared staging environments. When multiple teams or test suites share the same staging environment, your load test results become unpredictable. Another team's integration test suite might be hammering the database at the same time, inflating your response times. Dedicate an environment to load testing or implement scheduling to prevent overlap.
Setting thresholds too tight. If your performance gate fails on every third run due to natural variance in response times, developers will stop trusting it. Build in reasonable headroom and focus on catching genuine regressions rather than normal fluctuation. A threshold that catches a 50% regression reliably is more valuable than one that catches a 5% regression inconsistently.
Setting thresholds too loose. The opposite problem: a gate that never fails is not a gate. If your p95 threshold is 5 seconds and your baseline is 300ms, you will only catch catastrophic regressions. The threshold needs to be tight enough to catch meaningful degradation.
Not accounting for cold starts and warm-up. The first few seconds of a load test often show elevated response times as JIT compilers warm up, caches populate, and connection pools initialize. Either include a warm-up period that is excluded from results, or accept that your first few data points will be outliers and set thresholds accordingly.
Ignoring flaky results instead of investigating. When a load test occasionally fails and nobody can explain why, the common response is to add a retry or loosen the threshold. This is almost always wrong. Flaky performance test results usually indicate a real but intermittent problem -- a race condition, a connection timeout, a garbage collection pause. Investigate flaky failures; they are often more important than consistent ones.
Testing only happy paths. Real users do not follow a script. They navigate erratically, submit malformed data, and hit endpoints in unexpected orders. Include some error scenarios and edge cases in your load test to ensure your application degrades gracefully rather than catastrophically.
Conclusion
Adding load testing to your CI/CD pipeline transforms performance from a reactive firefighting exercise into a proactive quality measure. Start with a lightweight smoke test on every PR, layer in nightly comprehensive tests, and gate releases on stress test results. The upfront investment in setting this up pays for itself the first time it catches a regression that would have caused a production incident.
If you are new to load testing itself, start with our load testing tutorial to get a test running, then return here to automate it. For a broader look at performance testing methodology, see the performance testing guide.
Try LoadForge free for 7 days
Set up your first load test in under 2 minutes. No commitment.