To integrate performance testing into a DevOps toolchain, add a performance stage after you deploy to a test environment, make the test tool return a non-zero exit code when a threshold is broken, keep the results as a pipeline artifact, and let the deploy stage depend on that stage passing. The details differ per CI system, but the four steps are the same in GitHub Actions, GitLab CI, Jenkins and Azure DevOps.
This guide covers that wiring. What to run per pull request versus nightly, and why, is in continuous performance testing in CI/CD, and a tool-by-tool comparison is in comparing load testing tools for CI/CD.
Key takeaways
- A performance stage needs a deployed target, so it sits after the deploy-to-test step and before promotion.
- The gate is the tool’s exit code. k6 thresholds give you one; JMeter does not enforce a p95 limit on its own, so add a check on its results file.
- Archive the raw results even when the run fails: the failing run is the one you need to read.
- Run the smoke test per change and the long tests on a schedule, or the pipeline becomes the thing people bypass.
Where does performance testing fit in a CI/CD pipeline?
A load test needs something to load, so it cannot run in the build stage. A typical order looks like this:
| Stage | What runs | Performance role |
|---|---|---|
| Build | Compile, package, container image | None. Optionally lint the test scripts. |
| Test | Unit and integration tests | None. Fast feedback stays fast. |
| Deploy to test | Release the build to a staging or ephemeral environment | Creates the target for the next stage. |
| Performance | Smoke load test with thresholds | The gate. Fails the pipeline on a regression. |
| Promote or deploy | Release to production | Runs only if the performance stage passed. |
| After deploy | Scheduled tests and monitoring | See continuous validation. |
Keep the per-change test short, a few minutes on two or three key flows. Longer stress and soak tests belong on a schedule, where they cannot hold up a merge.
How do you fail a pipeline on a performance regression?
You express the pass criteria as thresholds and let the tool’s exit status carry the result, since every CI system treats a non-zero exit as a failed step.
k6. Thresholds go in the script’s options, as an aggregation, an operator and a value. Per the k6 thresholds docs, a failed threshold makes k6 finish with a failed status and a non-zero exit code. Checks do not affect the exit status, so put the limit in a threshold, or add a threshold on the checks metric (checks: ['rate>0.9']).
export const options = {
vus: 10,
duration: '2m',
thresholds: {
http_req_failed: ['rate<0.01'],
http_req_duration: ['p(95)<500'],
},
};
The 500 ms is a placeholder. Take yours from a baseline or a service level objective, and use a percentile rather than the average. Use abortOnFail in the long threshold form to stop a run early once a limit is broken.
JMeter. Run it in non-GUI mode: jmeter -n -t plan.jmx -l results.jtl -e -o report. The -e -o pair generates the HTML dashboard, and the output folder must not exist or must be empty (JMeter user manual). JMeter does not enforce a percentile limit for you (assertions mark individual samples as failed, nothing more), so fail the job from the results file. A short script is enough, and a division by zero on an empty file fails the job too, which is what you want:
import csv, sys
rows = list(csv.DictReader(open("results.jtl")))
error_rate = sum(r["success"] != "true" for r in rows) / len(rows)
elapsed = sorted(int(r["elapsed"]) for r in rows)
p95 = elapsed[int(0.95 * (len(elapsed) - 1))]
print(f"p95={p95} ms, errors={error_rate:.2%}")
sys.exit(1 if error_rate > 0.01 or p95 > 500 else 0)
How do you wire it into each CI system?
The snippets below run a k6 smoke test and keep its output. They show the structure; adjust paths, versions and environment names to your setup.
GitHub Actions
name: performance
on:
pull_request:
schedule:
- cron: '0 2 * * *'
jobs:
perf-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: grafana/setup-k6-action@v1
- run: k6 run --out json=results.json tests/smoke.js
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: k6-results
path: results.json
if-no-files-found: error
retention-days: 14
deploy:
needs: perf-smoke
runs-on: ubuntu-latest
steps:
- run: echo "promote the build"
Two details matter. A step after a failed step is skipped unless its if says otherwise, and GitHub’s documentation recommends !cancelled() over always() to run regardless of success or failure, because always() also runs after cancellation and can hang a workflow. And needs makes the deploy job run only when perf-smoke succeeded. Check the upload-artifact README for the current major version before you copy the pin.
GitLab CI
stages: [build, test, performance, deploy]
performance_smoke:
stage: performance
image:
name: grafana/k6
entrypoint: [""]
script:
- k6 run --out json=results.json tests/smoke.js
artifacts:
when: always
paths: [results.json]
expire_in: 2 weeks
deploy:
stage: deploy
script: [echo "promote the build"]
rules:
- if: $CI_COMMIT_BRANCH == "main"
Stages run in order and a later stage does not start once a job in an earlier one has failed, which is the gate. artifacts:when: always keeps the file from a failed job; the default on_success would drop exactly the output you need. The entrypoint: [""] override is how GitLab runs an image whose entrypoint is the tool itself.
Jenkins
pipeline {
agent any
stages {
stage('Performance smoke') {
steps {
timeout(time: 15, unit: 'MINUTES') {
sh 'k6 run --out json=results.json tests/smoke.js'
}
}
post {
always {
archiveArtifacts artifacts: 'results.json', allowEmptyArchive: true
}
}
}
stage('Deploy') {
steps { echo 'promote the build' }
}
}
}
A non-zero exit from sh fails the stage and the build, so Deploy never runs. The timeout step aborts a hung test, and archiveArtifacts fails the build by default when nothing matches, which is why allowEmptyArchive: true is set here: the real failure is the test, not the missing file. The agent needs k6 installed, or run the test in a container. If you would rather mark the build unstable than failed, wrap the step in catchError with a buildResult.
Azure DevOps
steps:
- script: k6 run --out json=$(Build.ArtifactStagingDirectory)/results.json tests/smoke.js
displayName: k6 smoke test
- publish: $(Build.ArtifactStagingDirectory)/results.json
artifact: perf-results
condition: succeededOrFailed()
publish is a shortcut for the Publish Pipeline Artifact task and is supported on Azure DevOps Services only; on Azure DevOps Server use the Publish Build Artifacts task (Microsoft Learn, October 2026). In a multi-stage pipeline, give the deploy stage dependsOn the performance stage with condition: succeeded(). Install k6 on the agent, or run it from the grafana/k6 container.
What can LoadFocus do in a pipeline?
When the load should come from the cloud rather than from the CI agent, LoadFocus runs JMeter, k6 and cloud load tests from 25+ cloud regions (loadfocus.com, October 2026). What the docs describe for pipelines:
- Pass/fail thresholds on a test: max P95, max P99, max error rate and min throughput. The verdict is readable through the Verdict API,
GET /api/v1/{loadtests|k6tests|jmetertests}/verdict, which needs the run name and run id and should be called on a finished run. - A load test gate action for GitHub Actions that runs a test, waits, reads the verdict and exits non-zero on a breach. Its README is in LoadFocus’s
integrations/github-actionfolder; read it for the currentuses:reference. - An API client for other CI systems, the npm package
@loadfocus/loadfocus-api-client, with guides for GitHub Actions, GitLab CI/CD, Jenkins, Azure DevOps and CircleCI. The examples runloadfocus-api jmeter run-testwith a--thresholdsstring and write JSON output, using an API key and team ID kept in the CI secret store. The Jenkins guide uses this client, not a plugin.
The docs do not state the exit code of the API client when a threshold is breached. Before you rely on it as a gate, run it once against a threshold you know will fail and confirm the job goes red, or read the JSON result and fail the job yourself. See the load testing page for the product overview.
What goes wrong when you add performance tests to a pipeline?
- A flaky target. If staging is shared or sized differently from production, results swing and the team learns to ignore red builds. Test against a dedicated or ephemeral environment and compare with the median of recent runs.
- Slow pipelines. Anything long on the per-change path gets skipped sooner or later. Move it to a schedule.
- Load from the CI agent. A hosted runner has limited CPU and one network location, so heavy tests distort themselves. Use it for smoke tests and run big ones on dedicated generators.
- Secrets in logs. Keep API keys in the CI secret store, never in the script or the command line.
- Gates nobody owns. Someone must update thresholds when the architecture changes. A gate that is red for no reason gets turned off.
For multi-service systems, see load testing microservices for deciding what to test per service.
Frequently Asked Questions
Where should performance tests run in a CI/CD pipeline?
After the build is deployed to a test or staging environment and before it is promoted to production. A short smoke test runs on each change, and longer tests run on a schedule.
How do I make a pipeline fail when performance gets worse?
Use a tool that returns a non-zero exit code on a broken threshold, such as k6, or parse JMeter’s results file in a script that exits non-zero. The CI system then fails the job and skips the deploy stage.
Should performance test results be archived?
Yes, including when the run fails. In GitHub Actions use if: ${{ !cancelled() }} on the upload step, in GitLab use artifacts:when: always, in Jenkins archive in a post { always } block, and in Azure DevOps use condition: succeededOrFailed().
Can I run JMeter in a CI pipeline?
Yes. Run it in non-GUI mode with -n -t plan.jmx -l results.jtl, optionally generate the report with -e -o <empty folder>, and fail the job from the results file because JMeter does not apply response-time limits itself.
Does performance testing slow down the pipeline?
Only if long tests sit on the per-change path. A smoke test of a few minutes is usually acceptable, and soak or stress tests can run overnight.
Sources
- k6 thresholds and exit status: Grafana k6 docs, Thresholds and JSON output (October 2026)
- JMeter non-GUI options and report folder rule: JMeter user manual, Getting Started (October 2026)
- GitHub Actions: upload-artifact, setup-k6-action and the expressions reference (October 2026)
- GitLab CI: CI/CD YAML reference and using Docker images (October 2026)
- Jenkins: Pipeline steps reference and basic steps (October 2026)
- Azure DevOps: steps.publish (October 2026)
- LoadFocus: CI/CD integration docs and pass/fail thresholds (October 2026)
Published with PostNext