Checks vs Thresholds

9 min read

Checks and thresholds look similar — both evaluate conditions against response data — but they serve completely different purposes. Confusing the two leads to load tests that report success while the system is failing, or tests that never finish because a threshold was written wrong.

The core distinction

Checks vs Thresholds

Checks

  • Per-request, per-iteration assertions

  • A failed check does NOT stop the test

  • Results accumulate in the checks metric

  • Report: '95.2% of checks passed'

  • Used for: response validation (status, body, latency)

  • Equivalent to: JMeter assertions

Thresholds

  • Whole-test pass/fail criteria

  • A failed threshold sets exit code 108

  • Evaluated continuously, reported at end

  • Report: 'PASS' or 'FAIL' per threshold

  • Used for: SLA compliance (p95, error rate)

  • Equivalent to: JMeter Summary Report limits + CI gate

Checks — per-request validation

check() takes a response object and an object of named condition functions. It returns true if all conditions pass, false otherwise. The test continues either way.

import { check } from 'k6';
import http from 'k6/http';
 
export default function () {
  const res = http.post('https://api.example.com/users',
    JSON.stringify({ name: 'Alice', email: 'alice@test.com' }),
    { headers: { 'Content-Type': 'application/json' } }
  );
 
  check(res, {
    'status is 201':          (r) => r.status === 201,
    'response has id':        (r) => r.json('id') !== undefined,
    'name matches':           (r) => r.json('name') === 'Alice',
    'duration under 500ms':   (r) => r.timings.duration < 500,
  });
}

The K6 output shows check pass rates:

✓ status is 201
✓ response has id
✗ name matches
  ↳  94% — 94 / 100
✓ duration under 500ms

A partially failing check — 94 out of 100 — does not end the test. It is a measurement, not a gate.

When to use check() vs if()

Use check() when you want the result recorded in the metrics (visible in output and dashboards). Use if() when you need to branch logic — do not make a subsequent request if the first one failed:

export default function () {
  const loginRes = http.post('/auth/login', JSON.stringify({
    email: 'user@test.com',
    password: 'pass',
  }), { headers: { 'Content-Type': 'application/json' } });
 
  // check() records the result but does not stop execution
  check(loginRes, { 'login succeeded': (r) => r.status === 200 });
 
  // if() guards the next request
  if (loginRes.status !== 200) {
    return;  // Skip the rest of this iteration
  }
 
  const token = loginRes.json('token');
  const profileRes = http.get('/profile', {
    headers: { Authorization: `Bearer ${token}` },
  });
  check(profileRes, { 'profile loaded': (r) => r.status === 200 });
}

Thresholds — test pass/fail criteria

Thresholds define the SLAs your test must meet. They are declared in options and evaluated against built-in metrics (and custom metrics):

export const options = {
  thresholds: {
    http_req_duration: ['p(95)<500'],    // 95th percentile under 500ms
    http_req_failed:   ['rate<0.01'],    // less than 1% errors
    checks:            ['rate>0.99'],    // more than 99% of checks pass
    http_reqs:         ['count>1000'],   // at least 1000 requests were made
  },
};

When any threshold fails, K6 exits with code 108 — allowing CI pipelines to detect a failed load test:

k6 run script.js
echo $?   # 108 if any threshold failed, 0 if all passed

Threshold expressions

Thresholds use a small expression syntax built into K6:

ExpressionMeaning
p(95)<50095th percentile under 500ms
p(99)<100099th percentile under 1000ms
avg<200Average under 200ms
med<300Median under 300ms
max<2000Maximum under 2000ms
rate<0.01Rate (proportion) under 1%
count>1000Total count above 1000

For http_req_duration, the valid aggregators are p(N), avg, med, min, max. For http_req_failed and checks, use rate. For http_reqs, use count.

Tagged thresholds — per-endpoint SLAs

Apply separate thresholds to individual endpoints using tags. Tag your requests first, then reference the tag in the threshold key:

export const options = {
  thresholds: {
    'http_req_duration{name:GetUsers}':    ['p(95)<200'],
    'http_req_duration{name:CreateOrder}': ['p(95)<800'],
    'http_req_duration{name:GetProduct}':  ['p(95)<150'],
    'http_req_failed{name:CreateOrder}':   ['rate<0.001'],
  },
};
 
export default function () {
  http.get('https://api.example.com/users', {
    tags: { name: 'GetUsers' },
  });
 
  http.post('https://api.example.com/orders', payload, {
    headers: { 'Content-Type': 'application/json' },
    tags: { name: 'CreateOrder' },
  });
 
  http.get('https://api.example.com/products/1', {
    tags: { name: 'GetProduct' },
  });
}

Now K6 fails the test if the 95th percentile for CreateOrder exceeds 800ms, regardless of how well GetUsers performs.

Aborting early on threshold failure

The abortOnFail option stops the test immediately when a threshold is breached:

export const options = {
  thresholds: {
    http_req_failed: [{
      threshold: 'rate<0.05',
      abortOnFail: true,
      delayAbortEval: '30s',   // wait 30s before evaluating to let metrics stabilise
    }],
  },
};

delayAbortEval prevents early termination during ramp-up when error rates are naturally higher before the system reaches steady state. Without it, a threshold checking error rate at the 10-second mark may abort a test that would have passed fine once the system warmed up.

⚠️ Common mistakes

  • Writing thresholds and expecting failed checks to trigger them. A threshold on checks uses the rate aggregator — checks: ['rate>0.99'] means 99% of check assertions must pass. A check failure by itself does not cause a non-zero exit code; only a threshold failure does.
  • Missing p() parentheses in threshold expressions. 'p95<500' is invalid syntax; 'p(95)<500' is correct. K6 will silently treat an invalid expression as always-passing.
  • Not tagging requests in multi-endpoint tests. A threshold on http_req_duration without tags applies to the aggregate across all endpoints. A slow batch export endpoint will cause a fast user-facing endpoint's threshold to fail. Tag every endpoint and write per-endpoint thresholds.
  • Setting abortOnFail without delayAbortEval. During ramp-up, error rates and latency are naturally higher. Without a delay, the test can abort in the first 10 seconds before the system has reached steady state.

🎯 Practice task

Build a script that demonstrates the difference between check failures and threshold failures. 30 minutes.

Use https://jsonplaceholder.typicode.com.

  1. Write a script with vus: 5, duration: '30s'. Add checks to each request:
    • GET /posts/1: check status 200 and res.json('title') is a non-empty string.
    • POST /posts with a JSON body: check status 201 and res.json('id') is a number.
  2. Add a threshold: http_req_duration: ['p(95)<500']. Run and observe — the test should pass (JSONPlaceholder is fast).
  3. Tighten the threshold to ['p(95)<1'] (1ms — impossible to meet). Run again. Observe exit code 108 and the FAIL marker in the output.
  4. Add a tagged request: GET /users/1 with { tags: { name: 'GetUser' } }. Add a threshold specifically for that tag: 'http_req_duration{name:GetUser}': ['p(95)<300'].
  5. Add checks: ['rate>0.99'] as a threshold. Deliberately break one check (check for status 999 instead of 200). Observe how the checks metric appears in the output and whether the threshold fails.

// tip to track lessons you complete and pick up where you left off across devices.