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 500msA 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 passedThreshold expressions
Thresholds use a small expression syntax built into K6:
| Expression | Meaning |
|---|---|
p(95)<500 | 95th percentile under 500ms |
p(99)<1000 | 99th percentile under 1000ms |
avg<200 | Average under 200ms |
med<300 | Median under 300ms |
max<2000 | Maximum under 2000ms |
rate<0.01 | Rate (proportion) under 1% |
count>1000 | Total 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
checksuses therateaggregator —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_durationwithout 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
abortOnFailwithoutdelayAbortEval. 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.
- Write a script with
vus: 5, duration: '30s'. Add checks to each request:GET /posts/1: check status 200 andres.json('title')is a non-empty string.POST /postswith a JSON body: check status 201 andres.json('id')is a number.
- Add a threshold:
http_req_duration: ['p(95)<500']. Run and observe — the test should pass (JSONPlaceholder is fast). - Tighten the threshold to
['p(95)<1'](1ms — impossible to meet). Run again. Observe exit code 108 and theFAILmarker in the output. - Add a tagged request:
GET /users/1with{ tags: { name: 'GetUser' } }. Add a threshold specifically for that tag:'http_req_duration{name:GetUser}': ['p(95)<300']. - 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.