Fail the Build, Not the Dashboard: k6 Thresholds as CI Gates
Learn how to configure k6 thresholds to enforce performance budgets in CI/CD, avoid flaky failures, and catch latency regressions before merge.
24 Mar 2026, 09:28 UTC

The gap between "it works" and "it works fast"
Your test suite passes. The build is green. But a change slipped through that doubled response time for your checkout endpoint, and nobody noticed until customers complained. The root cause is usually structural: performance tests run as reports, not as gates. k6 thresholds fix this by turning metric values into pass/fail criteria that your CI system can act on.
What a threshold actually does
In k6, a threshold is a declarative assertion attached to a metric. You define it in the options object or via CLI flags, and k6 evaluates it at the end of the run. If the condition is not met, k6 prints a failure summary and exits with a non-zero status code. That exit code is the bridge to automation: Jenkins, GitHub Actions, GitLab CI, and most other pipelines treat a non-zero exit as a failed step.
Thresholds support comparison operators such as <, >, and between. You can also express rates and percentiles, for example p(95)<300 or rate<0.01.
Scope matters: global versus tagged thresholds
A global threshold applies to every request in the test. That is useful for broad SLAs like "no more than 1% HTTP errors," but it is often too blunt for microservices or mixed workloads. If your login endpoint is latency-sensitive and your analytics ingestion is batch-oriented, a single global p95 will either be too loose for login or too strict for ingestion.
k6 lets you scope thresholds using sub-metrics created from tags. For example, if you tag requests with endpoint:checkout, you can write a threshold that only evaluates http_req_duration{endpoint:checkout}. This keeps critical paths under tight budgets without penalizing background traffic.
Worked example: a gated smoke test
The following script defines two thresholds: a realistic one for the 95th percentile of successful requests, and a deliberately impossible one to demonstrate CI failure behavior. Save it as perf-gate.js.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
vus: 10,
duration: '30s',
thresholds: {
// Realistic budget: 95% of requests under 500ms
'http_req_duration{expected_response:true}': ['p(95)<500'],
// Deliberate failure to prove the gate works
'http_req_duration': ['avg<1'],
},
};
export default function () {
const res = http.get('https://test-api.example.com/health', {
tags: { name: 'health' },
});
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(1);
}
Run it from any machine with k6 installed and network access to the target. No elevated permissions are required for a local run; CI runners need the same network egress your functional tests use.
# Local terminal or CI runner step
k6 run perf-gate.js
Expected check: The terminal summary will list threshold results. The avg<1 threshold will be marked as failed because a real HTTP round-trip cannot average under 1 ms. After the summary, verify the exit status:
# Linux/macOS shell or CI script step
echo $?
You should see a non-zero value. In a CI pipeline, that non-zero code marks the job as failed without extra scripting.
Risk: If you run this against a production system, the 10 virtual users and 30-second duration generate real load. Use a dedicated staging environment or a health endpoint designed for synthetic traffic.
Trade-offs and where thresholds mislead
Thresholds are powerful, but they introduce two common failure modes.
Flakiness from over-tight budgets. CI runners share CPU and network with other jobs. A threshold that passes on a dedicated load generator may fail intermittently on a shared GitHub Actions runner because of noisy-neighbor effects. Start with a budget slightly above your observed baseline, then ratchet it down as you harden the environment.
Averages hide tail latency. An avg threshold can pass while your p99 is terrible. A slow database query affecting 5% of users will not move the average much, but it will destroy the experience for those users. Prefer p(95) or p(99) for user-facing latency budgets.
A practical way to check your configuration is to run the same script twice: once locally against a stable staging endpoint, and once in your CI environment. Compare the reported percentiles. If the CI numbers are consistently worse, your thresholds need environment-specific headroom or a dedicated performance runner.
Actionable next step
Pick one critical endpoint. Tag its requests, measure its current p95 over a representative load profile, and set a threshold 10–20% above that number. Wire k6 run into your pipeline as a required check. When the build fails, you have caught a regression in minutes rather than in production. Tighten the budget only after the test has run green for several consecutive builds.
Because k6 evolves and threshold syntax can gain new capabilities, verify the exact operator and sub-metric syntax against the k6 documentation for your installed version before hard-coding it into a pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.