grafana/k6 · error

99

99

Error message

thresholds on metrics '%s' have been crossed

What it means

After the test ends and all samples are flushed to outputs, k6 evaluates thresholds one final time. If any were crossed, the run is failed with the list of breached metrics, exit code 99 (ThresholdsHaveFailed), and abort reason AbortedByThresholdsAfterTestEnd. This is k6's SLO gate: execution completed normally, but the acceptance criteria in options.thresholds were not met.

Solutions

  1. Check which metrics crossed — the message names them and the end-of-test summary marks those threshold rows ✗
  2. Fix the underlying performance problem in the system under test (the usual intent of the gate)
  3. If a threshold is wrong or unrealistic, adjust it in export const options = { thresholds: {...} }
  4. In CI, map exit code 99 to a distinct 'performance gate failed' status and archive the summary report

Example fix

// before
export const options = {
  thresholds: { http_req_duration: ['p(95)<100'] },
};

// after (if 100ms was an unrealistic target for this environment)
export const options = {
  thresholds: { http_req_duration: ['p(95)<300'] },
};
Defensive patterns

Strategy: try-catch

Try / catch

# treat 99 as the performance-gate result, not a generic error
k6 run script.js
ec=$?
case $ec in
  0) echo "passed";;
  99) echo "thresholds crossed — performance gate failed"; exit 99;;
  *) echo "k6 failed with exit code $ec"; exit $ec;;
esac

Prevention

When it happens

Trigger: Declaring thresholds like `export const options = { thresholds: { http_req_duration: ['p(95)<300'] } }` (or http_req_failed, checks, custom metrics) whose measured values exceed the limit at end of test; also applies to k6 cloud runs.

Common situations: CI pipelines using k6 as a pass/fail performance gate; regression detection after a deploy; thresholds copied from another environment with very different latency baselines.

Related errors


AI-assisted analysis of grafana/k6@3fcf5388d7 (2026-08-18). Data as JSON: /api/errors/714425729d8824b3. Report an issue: GitHub.

Appendix: source

Thrown at internal/cmd/run.go:376

	}()

	if thresholdsEnabled {
		finalizeThresholds := metricsEngine.StartThresholdCalculations(
			metricsIngester, runAbort, executionState.GetCurrentTestRunDuration,
		)
		handleFinalThresholdCalculation := func() {
			// This gets called after the Samples channel has been closed and
			// the OutputManager has flushed all of the cached samples to
			// outputs (including MetricsEngine's ingester). So we are sure
			// there won't be any more metrics being sent.
			logger.Debug("Finalizing thresholds...")
			breachedThresholds := finalizeThresholds()
			if len(breachedThresholds) == 0 {
				return
			}
			tErr := errext.WithAbortReasonIfNone(
				errext.WithExitCodeIfNone(
					fmt.Errorf("thresholds on metrics '%s' have been crossed", strings.Join(breachedThresholds, ", ")),
					exitcodes.ThresholdsHaveFailed,
				), errext.AbortedByThresholdsAfterTestEnd)

			if err == nil {
				err = tErr
			} else {
				logger.WithError(tErr).Debug("Crossed thresholds, but test already exited with another error")
			}
		}
		if finalizeThresholds != nil {
			defer handleFinalThresholdCalculation()
		}
	}

	defer func() {
		logger.Debug("Waiting for metrics processing to finish...")
		close(samples)
		waitOutputsFlushed()

View on GitHub (pinned to 3fcf5388d7)