K6's built-in metrics — http_req_duration, http_req_failed, http_reqs — measure HTTP behaviour. They tell you how fast requests are and how many fail. They do not tell you how many orders were placed, what the checkout success rate was, or how long a specific business flow took end-to-end. Custom metrics fill that gap.
The four metric types
K6 custom metric types
| How it works | Statistics shown | Best for | |
|---|---|---|---|
| Counter | Cumulative sum — only goes up | count, rate | Total events: orders placed, failed logins, retries |
| Gauge | Tracks a single current value — can go up or down | value, min, max | Current state: active sessions, queue depth, in-flight requests |
| Trend | Records a series of values and computes statistics over them | avg, min, med, max, p(90), p(95), p(99) | Latency of business flows: end-to-end checkout time, login-to-dashboard time |
| Rate | Proportion of values that meet a condition (0.0–1.0) | rate, passes, fails | Success ratios: payment success rate, search result relevance, cache hit rate |
Importing and creating custom metrics
All four types are imported from k6/metrics and instantiated outside the default function — in init code, so each VU shares the same metric definition:
import { Counter, Gauge, Trend, Rate } from 'k6/metrics';
// Instantiate once in init context — one instance per metric name
const ordersPlaced = new Counter('orders_placed');
const checkoutTime = new Trend('checkout_duration_ms');
const paymentSuccess = new Rate('payment_success_rate');
const activeCheckouts = new Gauge('active_checkouts');The string passed to the constructor ('orders_placed') is the metric name that appears in the K6 output and in Grafana. Use snake_case for consistency with built-in K6 metrics.
Counter — counting events
import { Counter } from 'k6/metrics';
import http from 'k6/http';
import { check } from 'k6';
const orderCreated = new Counter('orders_created');
const orderFailed = new Counter('orders_failed');
export default function () {
const res = http.post('https://api.example.com/orders',
JSON.stringify({ productId: 1, quantity: 2 }),
{ headers: { 'Content-Type': 'application/json' } }
);
if (check(res, { 'order created': (r) => r.status === 201 })) {
orderCreated.add(1);
} else {
orderFailed.add(1);
}
}The output shows cumulative counts and rates:
orders_created...: 847 14.117/s
orders_failed....: 23 0.383/sTrend — timing business flows
Trend records a series of numeric values and reports the full statistical distribution — the same percentile output as http_req_duration. Use it for multi-step flows where built-in metrics would only capture individual request times:
import { Trend } from 'k6/metrics';
import http from 'k6/http';
import { check } from 'k6';
const checkoutFlow = new Trend('checkout_flow_ms');
export default function (data) {
const flowStart = Date.now();
// Step 1: add to cart
http.post('https://api.example.com/cart/items',
JSON.stringify({ productId: 42, quantity: 1 }),
{ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` } }
);
// Step 2: apply promo code
http.post('https://api.example.com/cart/promo',
JSON.stringify({ code: 'LOAD10' }),
{ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` } }
);
// Step 3: complete checkout
const checkoutRes = http.post('https://api.example.com/orders',
JSON.stringify({ paymentMethod: 'card_test' }),
{ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` } }
);
checkoutFlow.add(Date.now() - flowStart);
check(checkoutRes, { 'checkout succeeded': (r) => r.status === 201 });
}The output shows the checkout flow's own percentile distribution:
checkout_flow_ms........: avg=1.23s min=0.85s med=1.18s max=3.12s p(90)=1.82s p(95)=2.10sRate — tracking success ratios
Rate.add() takes a boolean (or 0/1). K6 computes the proportion of true values:
import { Rate } from 'k6/metrics';
const cacheHitRate = new Rate('cache_hit_rate');
export default function () {
const res = http.get('https://api.example.com/products/1');
// X-Cache: HIT means the CDN or app cache served this
const isHit = res.headers['X-Cache'] === 'HIT';
cacheHitRate.add(isHit);
}Output:
cache_hit_rate...: 0.87 ✓ 870 ✗ 130Gauge — tracking current state
Gauge stores the most recently set value. It is useful for tracking in-flight state, though in practice most K6 users reach for Counter or Trend more often:
import { Gauge } from 'k6/metrics';
const activeUsers = new Gauge('active_user_sessions');
export default function () {
activeUsers.add(1); // VU starts a session
// ... run the test flow ...
activeUsers.add(-1); // VU ends the session — Gauge can decrease
}Thresholds on custom metrics
Custom metrics support the same threshold syntax as built-in metrics:
export const options = {
thresholds: {
'checkout_flow_ms': ['p(95)<3000'], // checkout must complete in 3s at p95
'payment_success_rate': ['rate>0.98'], // 98%+ payments succeed
'orders_failed': ['count<10'], // fewer than 10 failed orders total
},
};This is the key reason to use custom metrics: you can write CI gates on business outcomes, not just HTTP timings. A load test that meets http_req_duration p(95)<500 but has payment_success_rate rate=0.72 is still a business failure.
⚠️ Common mistakes
- Instantiating metrics inside the default function. If you write
const myCounter = new Counter('events')inside the default function, K6 creates a new metric object on every iteration. The metric name is the same, but the churn is wasteful. Create metric instances once in init code. - Using Gauge when you mean Trend. If you want to track latency distribution (p95, p99), use
Trend— it records every value and computes percentiles.Gaugeonly keeps the last value set, so you lose all distribution information. - Adding boolean conditions to Trend instead of Rate.
myTrend.add(isSuccess)adds 1 or 0 as a numeric data point — the p(95) of a boolean series is meaningless. UseRatefor proportions.
🎯 Practice task
Add business-level metrics to a CRUD test. 35 minutes.
Use https://jsonplaceholder.typicode.com.
- Create four custom metrics in init code:
new Counter('posts_created'),new Counter('posts_failed'),new Rate('post_success_rate'),new Trend('create_and_fetch_ms'). - In the default function:
- POST to
/postswithJSON.stringify({ title: 'K6 test', body: 'test body', userId: 1 }). - If status is 201, call
postsCreated.add(1)andpostSuccessRate.add(true). Otherwise, callpostsFailed.add(1)andpostSuccessRate.add(false). - Record
Date.now()before the POST. After a follow-upGET /posts/1, record the elapsed time withcreateAndFetch.add(Date.now() - start).
- POST to
- Add thresholds:
post_success_rate: ['rate>0.95']andcreate_and_fetch_ms: ['p(95)<2000']. - Run with
vus: 5, duration: '30s'. Find all four custom metrics in the output. - Deliberately break the success rate threshold by checking for status 999 instead of 201 in the condition. Confirm the test exits with code 108.