# Write k6 Thresholds That Fail CI for the Right Reasons

> A global p95 and error-rate pair stayed green while every payment took 1.5 seconds longer, because payments were under 3% of requests. This guide rebuilds the gates for a checkout flow with k6 v2.3.0: tagged thresholds tied to stated objectives, latency judged on successful responses, and count floors that catch a misspelt tag, whose thresholds otherwise pass on zero samples. Each gate is proven by an injected regression, and a CI wrapper separates exit code 99 from a broken script.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-21T13:30:00.000Z

- Updated: 2026-10-21T13:30:00.000Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/k6-thresholds-fail-ci-right-reasons

- Tags: k6, performance-testing, thresholds, load-testing, ci-cd, slo

**Quick answer:** Write each k6 threshold from a user-facing objective, attach it to the requests that objective is about with your own tags, and gate latency on successful responses with a percentile, never an average alone. Pair every tagged threshold with a `count` threshold on the same tag, because a sub-metric that receives no samples passes its latency and rate thresholds. Prove each gate by injecting the regression it exists to catch and confirming that exactly that gate fails. In CI, treat exit code 99 as "the system missed an objective" and every other non-zero code as "the test produced no verdict".

A checkout service ships with the two thresholds most k6 scripts start with: `http_req_duration: ['p(95)<800']` and `http_req_failed: ['rate<0.01']`. Someone adds 1.5 seconds to every payment authorisation. The two-minute run reports a p95 of 30.08 ms, a failure rate of 0.00%, and exit code 0. The pipeline goes green. Measured with a payment-specific threshold in the next run, the same change put payment p95 at 1.78 seconds.

Nothing was wrong with k6. The thresholds asked a question nobody cares about: how fast is the average request across a traffic mix dominated by product page views? [Part 1 of this series](/blog/automation-tutorials/choose-smoke-load-stress-spike-soak-test) ended with a charter that names the decision and pass criteria for each run. This part turns those criteria into thresholds that fail when users would be hurt, stay green when they would not, and tell CI which of the two happened.

**Versions checked on 2026-10-14:** k6 v2.3.0 (released 2026-09-21) is the current v2 release; v1.8.2 (released 2026-10-12) is the maintained v1 line. The outputs quoted here come from k6 v2.3.0 on an arm64 Mac, driving a local checkout stub written for Node.js 26.0.0. Exact numbers will differ on your hardware; the pass and fail outcomes are what carry over.

## Why the global pair stayed green

The stub behind these runs serves three endpoints: product pages in 8 to 16 ms, cart creation in 25 to 40 ms, and payment authorisation in 180 to 300 ms. Environment switches on the stub inject specific regressions, so each one can be switched on without touching the script. The script models a realistic funnel: every iteration views three products, and about one in ten goes on to checkout.

That funnel is the whole explanation. In the regressed run, 224 of 7,651 requests were payments, under 3% of the total. With payments that rare, the 95th percentile of all requests lands among product page views. Payments could have taken a minute each without moving it.

Errors hide the same way. In another run the stub returned a fast 502 for one payment in 25. The payment failure rate was 4.00%; the global `http_req_failed` rate was 0.11%, comfortably under 1%. Any endpoint that carries a small share of traffic, which describes most of the endpoints that make money, is invisible to a global threshold.

## From a user-facing objective to a k6 selector

Grafana's thresholds documentation frames thresholds as a way to codify SLOs, and the OpenTelemetry observability primer describes a good SLI as one that measures the service from the user's perspective. So start from what a user experiences, step by step, and only then find the k6 metric that measures it.

| User-facing objective | k6 metric | Selector and expression |
| --- | --- | --- |
| Product pages feel instant | `http_req_duration` (Trend) | `{journey:browse}`, `p(95)<200` |
| Payment answers quickly when it succeeds | `http_req_duration` (Trend) | `{step:payment,expected_response:true}`, `p(95)<800` |
| 99.5% of payment attempts succeed | `http_req_failed` (Rate) | `{step:payment}`, `rate<0.005` |
| A successful payment is actually authorised | `checks` (Rate) | `{step:payment}`, `rate>0.995` |
| The run contains enough payments to judge | `http_reqs` (Counter) | `{step:payment}`, `count>=150` |

The selectors use tags you set yourself, not the URL. k6 tags every HTTP request with a `name` that defaults to the requested URL, and the documentation recommends `http.url` or an explicit `name` for dynamic paths. Both make poor threshold keys. Built with `http.url`${BASE}/products/${id}``, the name tag was literally `${}/products/${}`, because the base URL is a template value too. A threshold written against the full URL matched nothing and passed. Your own tags (`journey`, `step`) survive host changes, refactors, and URL templating.

Rather than scatter numbers through `options`, keep the gates in one module where each number sits next to its justification:

```
// slo.js: every gate carries the reason for its number.
export const gates = [
  { metric: 'http_req_failed{step:payment}', expr: 'rate<0.005',
    why: 'payment SLO: 99.5% of authorisation calls succeed' },
  { metric: 'checks{step:payment}', expr: 'rate>0.995',
    why: 'same SLO, judged on the response body as well as the status' },
  { metric: 'http_req_duration{step:payment,expected_response:true}', expr: 'p(95)<800',
    why: 'checkout SLO: p95 payment step under 800 ms; baseline p95 290-295 ms over four runs' },
  { metric: 'http_req_duration{journey:browse}', expr: 'p(95)<200',
    why: 'product page SLO; baseline p95 16-17 ms' },
  { metric: 'http_reqs{step:payment}', expr: 'count>=150',
    why: 'below this the payment percentile rests on too few samples to gate on' },
  { metric: 'http_req_failed', expr: 'rate<0.01',
    why: 'catch-all for endpoints without their own gate' },
];

export function thresholdsFrom(list) {
  const out = {};
  for (const { metric, expr } of list) (out[metric] ??= []).push(expr);
  return out;
}
```

`thresholdsFrom` groups expressions into one array per metric key. That matters: the documentation warns that repeating a key in the `thresholds` object silently keeps only one of them, which a hand-maintained list of gates makes easy to do.

The script tags each request and each check. A check's third argument sets tags on the check, which is what makes `checks{step:payment}` a payment-only metric:

```
import http from 'k6/http';
import { check } from 'k6';
import { gates, thresholdsFrom } from './slo.js';

const BASE = __ENV.BASE_URL || 'http://127.0.0.1:3344';
const JSON_HEADERS = { 'Content-Type': 'application/json' };

export const options = {
  scenarios: {
    shoppers: {
      executor: 'constant-arrival-rate',
      rate: 20,
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 20,
      maxVUs: 100,
    },
  },
  thresholds: thresholdsFrom(gates),
};

export default function () {
  for (let i = 0; i < 3; i++) {
    const id = Math.floor(Math.random() * 500);
    http.get(http.url`${BASE}/products/${id}`, { tags: { journey: 'browse', step: 'product' } });
  }
  if (Math.random() >= 0.1) return; // about one visit in ten checks out

  const tags = { journey: 'checkout' };
  const cart = http.post(`${BASE}/cart`, JSON.stringify({ sku: 'SKU-1', qty: 1 }),
    { headers: JSON_HEADERS, tags: { ...tags, step: 'cart' } });
  check(cart, { 'cart created': (r) => r.status === 201 }, { ...tags, step: 'cart' });

  const pay = http.post(`${BASE}/payments`, JSON.stringify({ cartId: 'c1', amount: 1999 }),
    { headers: JSON_HEADERS, tags: { ...tags, step: 'payment' } });
  check(pay, {
    'payment authorised': (r) => r.status === 201 && r.json('status') === 'authorised',
  }, { ...tags, step: 'payment' });
}
```

The global `http_req_failed` gate stays as a catch-all for endpoints that have no objective of their own yet. It is a backstop, not the release criterion. The [k6 Script Generator](/tools/k6-generator) produces a reviewable starting script; the tagging and the gate module are the parts to add before it can gate anything.

### Latency: a percentile of successful responses

By default k6 tags a response `expected_response:true` when its status is 2xx or 3xx, and `http_req_failed` is the inverse of that tag. Unfiltered `http_req_duration` includes the failures. A service that fails fast pulls its own latency down: in the fast-failure run, the minimum unfiltered request duration was 1.94 ms against 7.26 ms for successful responses only. Gating latency on `expected_response:true` keeps the two questions apart. Errors are judged by the error gate, and speed is judged on requests that did what the user wanted.

If some non-2xx statuses are a correct outcome for your API, say a 409 for a duplicate submission, declare them with `http.expectedStatuses()`, either globally through `http.setResponseCallback()` or per request through the `responseCallback` parameter. Otherwise they count as failures and inflate the error gate.

The percentile matters as much as the filter. A run where one payment in ten took an extra two seconds, gated with both `avg<500` and `p(95)<800` on the payment selector, produced this:

```
    http_req_duration{step:payment,expected_response:true}
    ✓ 'avg<500' avg=439.13ms
    ✗ 'p(95)<800' p(95)=2.22s
```

One customer in ten waited over two seconds, and the average still passed. Averages fold the tail into the typical case; a percentile reports it. The [API Response Time Analyzer](/tools/api-response-time) is a quick way to look at a distribution before choosing which percentile reflects your objective, and [reading p95 from a HAR without guessing](/blog/tools-reviews/read-har-p95-without-guessing) covers why a percentile from few samples needs care.

### Errors: the budget has a resolution

An objective of 99.5% successful payments becomes `rate<0.005` on `http_req_failed{step:payment}`. What that tolerates depends on how many payments the run contains. At 226 payments, one failure is 0.44% and passes; two failures are 0.88% and fail. At 150 payments, a single failure is 0.67% and fails the gate. For a strictly-less-than expression to tolerate even one failure, the run needs more than 1 / 0.005 = 200 samples on that tag.

Neither outcome is wrong, but it should be a decision. If one failed payment in a short CI run is enough to block a release, keep the run short. If it is not, lengthen the run or raise the rate until the expected sample count clears the threshold's resolution. The [HTTP status code reference](/tools/http-status) helps decide which statuses belong in the failure count at all.

### Volume: an empty sub-metric passes

Rename `step: 'payment'` to `step: 'pay'` in a refactor, or mistype the tag in a threshold key, and the tagged thresholds keep evaluating, over nothing. A three-iteration probe with a misspelt tag printed:

```
    http_req_duration{step:paymnet}
    ✓ 'p(95)<800' p(95)=0s

    http_reqs{step:paymnet}
    ✗ 'count>=1' count=0
```

The Trend reports zeros and passes. Rate thresholds on an empty selector also passed, including `checks{step:paymnet}: ['rate>0.995']`, displayed as a tick next to `rate=0.00%`. The k6 source for v2.3.0 shows why: a Rate sink with no samples never computes a rate, and a threshold whose value is missing is treated as passing. Only the Counter reported zero and failed.

k6 does catch a misspelt metric name. `http_req_duraton` stopped the run before it started with `invalid threshold defined on http_req_duraton; reason: no metric name "http_req_duraton" found`. A tag value cannot be checked that way, because tag values only exist once samples arrive. That is the job of the `count` gate: one per tag selector you rely on, set below the smallest volume a healthy run produces. Across eight two-minute runs of this script, the payment count ranged from 220 to 262, so a floor of 150 does not flap, and it also fails the run if a script error stops iterations before they reach the payment step.

## Make each gate fail once on purpose

A threshold that has never failed is untested code. The stub's switches made it possible to inject one regression per run and record which gates fired:

| Run | Injected change | Gates that failed | Exit code |
| --- | --- | --- | --- |
| Baseline (four runs) | None | None; payment p95 289.95 to 294.41 ms | 0 |
| Slow payments | +1,500 ms on every payment | Payment latency only (p95 1.78 s) | 99 |
| Payment tail | +2,000 ms on one payment in ten | Payment p95 (2.22 s); the payment average passed | 99 |
| Fast payment failures | 502 on one payment in 25 | Payment error rate (4.00%) and payment checks (96.00%) | 99 |
| Global pair only | +1,500 ms on every payment | None; global p95 30.08 ms | 0 |

Each injected regression tripped the gate written for it and left the others green, which is the property to look for. If a latency regression had also tripped the error gate, a timeout or a retry would be counting slowness as failure, and the release discussion would start from the wrong question.

Against a real service you cannot add switches to production code, but the test environment usually offers something: a feature flag that adds delay to one downstream call, a fault-injection setting on the service mesh or reverse proxy in staging, or a deliberately broken build of the previous release. Run it once when a gate is written and again whenever its selector or expression changes. Keep the failing output with the passing one, because together they are the evidence that the gate can see what it claims to.

The baseline runs set the headroom. Payment p95 sat between 290 and 295 ms, so the 800 ms objective leaves room for harmless variation, and also means a regression to 700 ms passes. That is correct for an SLO gate: users are still inside the objective. If you also want an early warning on drift, make it a separate threshold derived from the spread of several baselines taken on the same environment, never from one run on a cold or idle system, and write that derivation in its `why` field.

## Let CI distinguish a failed objective from a broken test

k6 reserves exit code 99 for failed thresholds. Other non-zero codes mean something else: 104 for invalid configuration, 107 for a script exception, 108 for a script that called `test.abort()`. A CI step that only checks for non-zero turns a typo into a "performance regression" and sends someone looking at the service. Each case produced its own code here: a malformed percentile, `p(950)<800`, exited 104 with `malformed percentile value, provide a number between 0 and 100`, and an exception thrown at init time exited 107.

```
#!/usr/bin/env bash
# perf-gate.sh: run the checkout gate and tell CI which kind of failure it was.
set -u
k6 run --quiet "${1:-checkout.js}"
code=$?
case "$code" in
  0)   echo "perf gate: all thresholds passed" ;;
  99)  echo "perf gate: a threshold failed, see threshold-report.json"; exit 1 ;;
  *)   echo "perf gate: no verdict, k6 exited $code (script, config or environment problem)"; exit 2 ;;
esac
```

One gap remains. An exception inside the default function, such as reading a field from a response whose shape changed, is logged per iteration and does not change the exit code. A probe that threw a `TypeError` on every iteration exited 0. The per-tag `count` gates are what turn that into a failure, because the requests after the exception never happen.

The other half of the CI evidence is a threshold report a reviewer can read without the console log. `handleSummary()` receives every metric with its thresholds, each carrying an `ok` flag:

```
export function handleSummary(data) {
  const lines = [];
  const report = [];
  for (const { metric, expr, why } of gates) {
    const m = data.metrics[metric];
    const ok = m.thresholds[expr].ok;
    report.push({ metric, expr, ok, why, values: m.values });
    lines.push(`${ok ? 'PASS' : 'FAIL'}  ${metric}  ${expr}`);
  }
  return {
    stdout: lines.join('\n') + '\n',
    'threshold-report.json': JSON.stringify(report, null, 2),
  };
}
```

For the slow-payment run it printed:

```
PASS  http_req_failed{step:payment}  rate<0.005
PASS  checks{step:payment}  rate>0.995
FAIL  http_req_duration{step:payment,expected_response:true}  p(95)<800
PASS  http_req_duration{journey:browse}  p(95)<200
PASS  http_reqs{step:payment}  count>=150
PASS  http_req_failed  rate<0.01
```

Exporting `handleSummary` replaces the default end-of-test summary, so keep the default output elsewhere if your team reads it. Prefer this over the older `--summary-export` file, which Grafana now discourages, and which inverts the meaning of the flag: its thresholds map holds `true` for a threshold that failed. The tail run's export read `{'avg<500': false, 'p(95)<800': true}`, and in the v2.3.0 source the legacy writer sets each entry to `!threshold.ok`. A pipeline that reads `true` as "passed" reports the opposite of what happened.

## Three ways a gate decays after it ships

**The number moves to match the build.** The symptom is a commit that changes a threshold value in the same pull request as the code that started failing it. Keeping the gates in one module with a `why` per entry makes this reviewable: a changed number with an unchanged reason is the tell. A legitimate change comes with a new SLO decision or a new set of baselines. Keep `--no-thresholds`, which disables threshold evaluation entirely, out of pipeline definitions.

**The traffic mix changes under the gate.** The symptom is a tagged threshold whose sample count falls release after release while it keeps passing. Someone lowered the checkout share in the script, or moved payment into a different scenario. Watch the `count` values in the threshold report, not only the pass marks.

**A threshold string that never parsed as intended.** Before k6 v2.1.0, a percentile outside 0 to 100 was silently accepted. Pull request #6013 in that release made it a parse error. If your CI image pins an older k6, upgrading may turn a long-green gate into an exit 104, and that is the gate finally being evaluated. An AI assistant can draft threshold syntax and point out odd values in a report, but it cannot supply the numbers: each one needs an objective or a measured baseline behind it.

## Apply this today

Pick the k6 script your pipeline already runs and list its thresholds. For each, write down the user-facing objective it encodes; any you cannot name are candidates for deletion or for the catch-all. Tag the requests for your most valuable journey step, add a latency percentile on `expected_response:true`, an error rate, and a `count` floor on the same tag. Then inject one regression in a test environment and confirm that only the intended gate fails with exit code 99. Keep the threshold report from the failing run beside the passing one, and wrap the run so CI treats 99 differently from every other non-zero code.

## Questions about k6 thresholds in CI

### Should the latency gate use p95 or p99?

Use the percentile your objective is written in, then check the sample count supports it. A p99 over 226 payments is decided by the slowest two or three requests, so one slow outlier can fail the run. If the objective really is p99, run long enough that the tail is made of more than a handful of samples.

### Does every endpoint need its own threshold?

No. Give a threshold to each step that has a user-facing objective and enough traffic to measure, and let the global error gate cover the rest. Twenty tagged thresholds with no stated objective recreate the original problem: numbers nobody can defend, adjusted whenever they fail.

### Are groups a better selector than tags?

Groups work, and the thresholds documentation shows `group_duration{group:::name}` thresholds. The group tag value is the full path of nested group names joined by `::`, so wrapping code in another group changes it, and the old threshold then matches nothing. Request-level tags are more stable, and they can still be combined in one selector, as `{step:payment,expected_response:true}` does.

### Do thresholds behave the same in Grafana Cloud k6?

The expressions are the same, but in cloud runs Grafana evaluates thresholds every 60 seconds, so an `abortOnFail` stop can arrive up to a minute late. Since k6 v2.0.0, cloud runs aborted for reasons other than a threshold exit with 97 rather than 0, which a wrapper like the one above reports as "no verdict".

## Primary references

- [Thresholds, in the Grafana k6 documentation](https://grafana.com/docs/k6/latest/using-k6/thresholds/). Thresholds as codified SLOs, expression syntax and aggregation methods per metric type, the silent loss of repeated keys, tagged sub-metric thresholds, tags on checks, group thresholds, and 60-second evaluation in cloud runs.

- [Built-in metrics reference (Grafana k6)](https://grafana.com/docs/k6/latest/using-k6/metrics/reference/). The types of `http_req_duration`, `http_req_failed`, `http_reqs` and `checks`, and that `http_req_failed` follows the response callback.

- [setResponseCallback() API reference](https://grafana.com/docs/k6/latest/javascript-api/k6-http/set-response-callback/). The `expected_response` tag, the 200 to 399 default, and disabling it with `null`.

- [expectedStatuses() API reference](https://grafana.com/docs/k6/latest/javascript-api/k6-http/expected-statuses/). Declaring individual statuses and ranges as expected.

- [HTTP Params reference](https://grafana.com/docs/k6/latest/javascript-api/k6-http/params/). The per-request `responseCallback` parameter.

- [Tags and Groups guide](https://grafana.com/docs/k6/latest/using-k6/tags-and-groups/). System tags including `name` and `expected_response`, and the `::`-joined group tag value.

- [HTTP Requests guide](https://grafana.com/docs/k6/latest/using-k6/http-requests/). The `name` tag defaulting to the URL, and grouping dynamic URLs with `http.url`.

- [Custom summary with handleSummary()](https://grafana.com/docs/k6/latest/results-output/end-of-test/custom-summary/). Writing summary data to stdout and files, and replacing the default summary.

- [k6 options reference](https://grafana.com/docs/k6/latest/using-k6/k6-options/reference/). `--no-thresholds`, and `--summary-export` being discouraged.

- [k6 exit code constants at v2.3.0](https://github.com/grafana/k6/blob/v2.3.0/errext/exitcodes/codes.go). Codes 99, 104, 107 and 108 and what each signals.

- [k6 threshold evaluation source at v2.3.0](https://github.com/grafana/k6/blob/v2.3.0/metrics/thresholds.go). Rate sinks with no samples leave the rate unset, and a missing value is treated as passing.

- [k6 legacy summary writer at v2.3.0](https://github.com/grafana/k6/blob/v2.3.0/internal/js/summary-wrapper.js). The `--summary-export` threshold entries written as `!threshold.ok`.

- [Release notes for k6 v2.1.0](https://github.com/grafana/k6/releases/tag/v2.1.0). Out-of-range percentiles rejected at parse time (#6013).

- [Release notes for k6 v2.0.0](https://github.com/grafana/k6/releases/tag/v2.0.0). Exit code 97 for cloud runs aborted for non-threshold reasons.

- [Release notes for k6 v2.3.0](https://github.com/grafana/k6/releases/tag/v2.3.0). The version used for every run in this article.

- [OpenTelemetry observability primer](https://opentelemetry.io/docs/concepts/observability-primer/). SLIs measured from the user's perspective, and SLOs as SLIs tied to business value.

## Continue reading on AutomationTester.in

- [Build a k6 Load Model from Real Traffic, Not a VU Count](https://automationtester.in/blog/tools-reviews/generate-k6-load-model-traffic-assumptions)
- [Measure Mobile Performance as a User Journey](https://automationtester.in/blog/automation-tutorials/mobile-performance-testing-user-journey)
- [Read a HAR p95 Without Blaming the Wrong API](https://automationtester.in/blog/tools-reviews/read-har-p95-without-guessing)
- [Understand Appium 3 Drivers, Plugins, and Boundaries](https://automationtester.in/blog/automation-tutorials/appium-drivers-plugins-test-boundaries)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)

Source: [Write k6 Thresholds That Fail CI for the Right Reasons](https://automationtester.in/blog/automation-tutorials/k6-thresholds-fail-ci-right-reasons) by Shashank Rawlani.
