skip to content

How often does k6 re-evaluate thresholds during a run, and what does the final pass judge differently?

level: seniorimportance: nice to knowfreq 28%

answer

  1. judged repeatedly, not once
  2. a short fixed tick while running
  3. the last pass skips nothing
  4. an empty metric only fails at the end

basics

~20 s

k6 re-evaluates thresholds every two seconds while a run is live, skipping metrics with no samples yet. The final pass after the run skips nothing, so a rule on a never-exercised metric breaches there and exits 99.

solid answer

~40 s

When a k6 run defines any threshold, a background goroutine evaluates every rule on a fixed **two-second** tick against the elapsed run duration; that tick is what lets a rule configured to stop the run act mid-run. After the run ends, k6 flushes all outputs and evaluates once more — and this final pass differs in one way: the in-run tick **skips metrics whose sink is empty**, while the final pass evaluates them. So a rule on a metric your script never exercises is invisible for the whole run and then breaches at the last moment, exiting **99**. From a CI job that reads as minutes of clean progress followed by a sudden non-zero code.

code

javascript · 16 lines
javascript
import http from 'k6/http';

export const options = {
  vus: 1,
  duration: '30s',
  thresholds: {
    // ws_sessions is a real built-in metric, so the rule loads.
    // This HTTP-only script never emits a sample for it, so every
    // 2s tick skips it and only the final pass judges it: exit 99.
    ws_sessions: ['count > 0'],
  },
};

export default function () {
  http.get('https://quickpizza.grafana.com');
}

go deeper

for a junior

Know that k6 checks thresholds repeatedly while a run is in progress and once more after it ends, rather than only at the finish line.

for a middle

Explain the fixed two-second tick, that it evaluates against elapsed duration so far, and that it is what lets an early-stopping rule act before the run is over.

for a senior

Own the asymmetry: the in-run passes skip empty metrics and the final pass does not, so a rule on a metric the script never exercised passes silently all run and breaches at the end.

for a principal

The angle worth arguing is what a rule on a never-exercised metric should mean by default — k6 chooses failure, which turns an unwritten part of the script into a verdict.

## Thresholds are judged repeatedly, not once k6 does not wait until the end of a run to evaluate thresholds. When a run has at least one threshold defined, k6 starts a background goroutine that ticks on a fixed interval — `thresholdsRate = 2 * time.Second` in `internal/metrics/engine/engine.go` — and on every tick evaluates every metric that carries thresholds against the samples accumulated so far. Two things follow immediately: - Each evaluation is against **the run's elapsed duration at that moment**, not its configured duration. A rule whose value depends on elapsed time therefore reads differently at second 4 than it will at the end. - The tick is what allows a threshold configured to stop the run at its first breach to act *during* the run rather than after it. (That configuration field is threshold-syntax's subject; what concerns the exit code is that this road also ends at **99**.) If a run defines no thresholds at all, the goroutine is never started — `StartThresholdCalculations` returns nil — and there is no finaliser either. ## The final pass is different in one specific way When the run ends, k6 closes the sample channel, waits for every output to flush, and then runs one last evaluation. That last pass is not just "the tick, once more". The in-run tick is called with `ignoreEmptySinks = true` and the final pass with `false`, and that single boolean changes which metrics are judged: | | during the run, every 2s | once, after the run ends | |---|---|---| | metrics with samples | evaluated | evaluated | | metrics with **no** samples | **skipped entirely** | **evaluated** | | can stop the run early | yes | no, the run is already over | | result feeds the exit code | via the early-stop path | via the final breach list | "Empty" is per sink type: a counter is empty until its first sample, a trend until its first observation, a rate until its first true-or-false entry, a gauge until its first value. ## The consequence that surprises people A threshold on a metric that **never receives a sample** is invisible for the entire run and then breaches at the very last moment. Consider a run whose script only makes HTTP requests, with a rule requiring at least one WebSocket session. `ws_sessions` is a registered built-in metric, so k6 accepts the rule at load time — the metric exists, it simply never gets a sample. During the run its sink stays empty, so every two-second tick skips it and the live progress output shows nothing wrong. At the end the final pass evaluates it anyway: an empty counter reads as zero, zero fails the rule, the metric joins the breached list, and the process exits **99**. From a CI job this looks like the least helpful possible failure: minutes of green progress output, then a non-zero code in the last second. The diagnosis is always the same — look for the breached metric named in the log line and check whether it recorded any samples at all, because a rule on a metric your script never exercises fails by default rather than being ignored. ## Practical points to carry away 1. **Live progress is not a preview of the verdict.** A run can look clean for its whole duration and still breach on the final pass. 2. **A rule on a metric you never exercise is not a no-op.** It is a rule that fails at the end. 3. **The evaluation interval is fixed at two seconds**, so an early-stopping rule reacts within about that window rather than instantly. 4. **`--no-thresholds` removes both passes**, not just the final one, so neither the tick nor the finaliser exists. 5. **The breach list is sorted and logged** as `thresholds on metrics '<names>' have been crossed`, which is the fastest route from a bare 99 to the actual cause. 6. **The tick only exists while the run does.** Once the sample channel is closed there are no further periodic evaluations, only the single final pass, so nothing arriving afterwards can move the verdict one way or the other. ## In k6 v2 The two-second interval and the empty-sink asymmetry are both current in k6 v2.x. Note that the evaluation cadence is internal to a single k6 instance: nothing about it is exposed as an option, and there is no flag to change it.

  • Why does k6 skip empty metrics during the run but judge them at the end?
    Because a metric that has not yet recorded anything says nothing about the run in progress — judging it early would breach every rule in the first two seconds of every test. Once the run is over, an empty sink is real information: the script never exercised that metric, so the rule is evaluated against its zero value and fails.
  • Does k6 start the threshold evaluation goroutine for a run with no thresholds?
    No. `StartThresholdCalculations` returns nil when no metric carries thresholds, so neither the ticking goroutine nor the finaliser exists and the run can never exit 99. `--no-thresholds` reaches the same state deliberately, by gating both passes on a runtime option.
  • A k6 run showed clean progress for ten minutes and then exited 99. Where do you look first?
    The log line `thresholds on metrics '<names>' have been crossed`, which names every breached metric. Then check whether those metrics recorded any samples at all: a rule on a metric the script never exercised is skipped by the in-run ticks and only fails on the final pass, which is exactly this symptom.

saying these in an interview costs you the question

  • Says thresholds are only evaluated once, at the end
  • Thinks a rule on an unused metric is silently ignored
  • Assumes clean live progress guarantees a zero exit
  • Believes the evaluation interval is configurable
  • Expects an empty metric to be skipped by the final pass too