Before running

Run from a remote machine, not your laptop. Client CPU contention inflates http_req_tls_handshaking — can show 3s p99 when actual TLS cost is 58ms.

Stabilise the environment first. Check k9s for pods with high restart counts before starting. A pod restarting mid-test makes results uninterpretable.

Connection reuse

k6 has two separate options controlling connection reuse:

OptionScopeEffect when true
noConnectionReuseWithin an iterationA fresh TCP connection is opened for every HTTP request, even within the same iteration
noVUConnectionReuseBetween iterationsConnections are cleaned up after each iteration; within an iteration, keep-alive still applies

By default both are false: k6 reuses connections within an iteration and carries them over to the next iteration of the same VU.

The between-iteration race condition occurs when a server-side idle timeout fires on a kept-alive connection while the VU is between iterations:

  1. The server decides an idle connection has timed out and sends a TCP FIN to close it.
  2. While that FIN is still in-flight (or sitting unprocessed in the OS receive buffer), the VU sends a new request on the same socket.
  3. The server receives the request on an already-closed socket and responds with TCP RST.
  4. k6 sees a “connection reset by peer” or EOF — which looks like a server error but is just a connection lifecycle mismatch.

The root cause is a timeout mismatch: as long as the client timeout ≥ server timeout, the server can close first and trigger this race. Use noVUConnectionReuse: true to fix it — connections are reset after each iteration but reused within it, so the overhead is lower than noConnectionReuse.

TLS in UAT

export const options = {
  insecureSkipTLSVerify: true,
};

UAT environments might have inconsistent certs across pods. Without this, you get spurious TLS errors that look like server failures.

Tagging for per-endpoint metrics

k6 automatically applies a scenario system tag to every metric, but scenario tags don’t create per-endpoint sub-metric breakdowns — they just let you filter by scenario after the fact. To get http_req_waiting{test:X} scoped to a specific request, you must tag at the request level:

http.get(url, { tags: { test: 'my-endpoint' } });

Scenario-level tags (set in options.scenarios[*].tags) propagate to all requests in that scenario but still require a matching threshold to surface as a sub-metric in the summary. Request-level tags are more precise and are the correct unit for per-endpoint breakdown.

Exposing sub-metrics in handleSummary

In the open-source k6 CLI, sub-metrics are only computed and included in the handleSummary data object when a threshold is defined for that tag combination. Without a threshold, k6 doesn’t materialise the sub-metric at all — it won’t appear in the summary even if requests were tagged correctly.

Use a dummy threshold that always passes to force the sub-metric to appear without enforcing any pass/fail condition:

export const options = {
  thresholds: {
    'http_req_waiting{test:my-endpoint}': ['p(99)>=0'], // always true, never fails
  },
};

See also