latency-percentile-analyzer
Interprets latency distributions from k6 load tests beyond the p95/p99 gate: reads percentile summaries and JSON exports to identify tail shape, computes the tail ratio (p99/p50) as a distribution-spread signal, detects bimodal distributions, explains coordinated omission and why naive p99 values are optimistic under sustained load, and distinguishes request-rate from concurrency models. Use when a k6 threshold passes but the system still feels slow, when p99 is suspiciously low during ramp-up, or when the team needs to explain why tail latency is high rather than just observing that it is.
Install with skills.sh (any agent)
npx skills add testland/qa --skill latency-percentile-analyzerlatency-percentile-analyzer
This skill walks the interpretation workflow for k6 latency distributions. Passing a p95 threshold is necessary but not sufficient: a system with a bimodal distribution, an inflated tail, or coordinated omission in its measurement can pass every gate while hiding a real user experience problem.
Step 1 - Expand the default percentile set
k6's default summary shows avg, min, med, max, p(90), p(95) per --summary-trend-stats (opens in new window). That range omits p99 and p99.9, where tail pathologies live. Before interpreting anything, expand the output:
k6 run \
--summary-trend-stats="avg,min,med,max,p(50),p(90),p(95),p(99),p(99.9)" \
script.jsOr fix it in the script so every run uses the same stats:
export const options = {
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(90)', 'p(95)', 'p(99)', 'p(99.9)'],
};(Per k6-options reference (opens in new window).)
For downstream analysis, export a JSON summary via handleSummary:
export function handleSummary(data) {
return { 'summary.json': JSON.stringify(data) };
}k6 records http_req_duration (send + wait + receive), http_req_waiting (TTFB only), and the sub-phase breakdown in separate Trend metrics per k6 metrics reference (opens in new window). Always pull http_req_waiting alongside http_req_duration: a high p99 on http_req_duration but a normal p99 on http_req_waiting points to response-body transfer or connection-reuse, not server processing.
k6's Trend metric stores all recorded values in a sorted slice and computes percentiles via linear interpolation between neighboring values (verified in github.com/grafana/k6/blob/master/metrics/sink.go). This is accurate but stores every sample in memory; for very long runs with millions of requests, use --out json and post-process with an external histogram library.
Step 2 - Read the distribution shape
Given an expanded summary, apply this reading order:
2a - Check the spread ratio (p99/p50)
Compute the tail ratio: p(99) / p(50).
| Ratio | Signal |
|---|---|
| < 2x | Narrow distribution - system is predictable under this load. |
| 2-5x | Moderate tail - investigate at higher concurrency before signing off. |
| 5-10x | Wide tail - GC pauses, lock contention, or connection pool exhaustion are common causes. |
| > 10x | Bimodal candidate or coordinated omission artifact - see Steps 2b and 3. |
The tail ratio is a single number that summarizes how differently the slow requests behave from the typical ones. A p99 of 800ms with a p50 of 100ms (8x ratio) is a different system than a p99 of 220ms with p50 of 200ms (1.1x ratio), even if both pass p(95)<500.
2b - Check for bimodal shape
A bimodal latency distribution has two peaks: one cluster around the fast path and a second cluster at a much higher value. Common causes include:
Detection heuristics from the summary stats:
If you have access to the raw data (via --out json), plot a histogram with narrow buckets (1ms or 5ms width) to confirm two modes visually before acting on the heuristics.
2c - Cross-check the sub-metrics
Per k6 metrics reference (opens in new window), http_req_duration equals the sum of http_req_sending + http_req_waiting + http_req_receiving.
If p(99) of http_req_duration is high:
Step 3 - Understand coordinated omission
This is the most important concept for interpreting load test p99 values.
What coordinated omission is
In a typical load test, a virtual user sends a request and waits for the response before sending the next one. When the server slows down, the VU slows down with it. The VU and the server are coordinating: during a slow period, fewer requests are issued, so fewer slow samples are recorded.
The result, illustrated in the HdrHistogram README (opens in new window): imagine a server that responds in 1ms for 100 seconds, then pauses for 100 seconds, then resumes. A naive measurement records 10,000 samples at 1ms and 1 sample at 100,000ms. The naive histogram reports ~99.99% of results at or below 1ms. The corrected picture is closer to ~50% at 1ms and 50% distributed across the pause - because every user who arrived during the pause experienced a long wait, not just the one whose request happened to be in-flight.
The same phenomenon applies to VU-based load testing: under a server stall, VUs queue up rather than issuing new requests at the original rate. The requests that complete quickly before and after the stall dilute the tail.
Why p99 lies during ramp-up
During the ramp-up stage, VU count is low and think-time between iterations keeps the server below its saturation point. Samples accumulate at low latencies. When VUs reach plateau, a fraction of requests experience queuing delay, but by that time the histogram already has a large base of fast samples. The p99 computed over the full run can look much better than the p99 computed over the plateau-only window. Always inspect time-windowed summaries (export raw JSON and bucket by timestamp) or use --summary-export only from the plateau phase by separating ramp-up and plateau into distinct scenario stages.
How HdrHistogram corrects for it
HdrHistogram's recordValueWithExpectedInterval(value, expectedInterval) detects when a recorded value exceeds the expected sampling interval and synthesizes intermediate samples to represent the requests that were waiting but never measured (per HdrHistogram README (opens in new window)). The synthesized values are linearly spaced between expectedInterval and the recorded value, filling in the distribution the VU-coordination hides.
k6 does not apply coordinated omission correction by default. Its Trend sink stores raw values. If the test uses sleep() to model think-time and a fixed VU count, the concurrency model naturally prevents one VU from issuing a second request while waiting - so under a server pause, request rate drops. This is the mechanism by which k6 results can understate tail latency under bursty load.
For accurate tail measurement under realistic arrival rates, consider:
Step 4 - Request-rate vs. concurrency models
Understanding which model your test uses changes how you interpret the results.
| Model | k6 executor | How latency is measured |
|---|---|---|
| Concurrency (VU) | constant-vus (default) | VU holds a slot for the duration of the request. Throughput adapts to latency. |
| Request-rate | constant-arrival-rate | k6 issues requests at a fixed rate. If VUs run out, k6 reports dropped_iterations. |
With constant-vus, a high p99 might be masking the fact that throughput also dropped during those slow periods. The system's capacity degraded; the histogram only shows that some requests were slow, not that many were never sent.
With constant-arrival-rate, slow requests cause VU starvation. Watch dropped_iterations alongside percentiles. If dropped_iterations > 0, the p99 you see is from the requests that did complete - it excludes the dropped ones which represent an infinite-latency from the user's perspective.
A concrete read-back pattern using the summary.json export:
jq '{
p50: .metrics.http_req_duration.values["p(50)"],
p95: .metrics.http_req_duration.values["p(95)"],
p99: .metrics.http_req_duration.values["p(99)"],
tail_ratio: (.metrics.http_req_duration.values["p(99)"] /
.metrics.http_req_duration.values["p(50)"]),
dropped: .metrics.dropped_iterations.values.count
}' summary.jsonA non-null dropped count combined with a low tail ratio is a red flag: the fast percentiles are artificially low because the slow requests were never issued.
Step 5 - Thresholds to gate on
Per k6 thresholds (opens in new window), set thresholds on the metrics that surface the patterns above:
export const options = {
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(90)', 'p(95)', 'p(99)', 'p(99.9)'],
thresholds: {
// Gate on p99 as well as p95 - the tail matters.
http_req_duration: ['p(95)<500', 'p(99)<1500'],
// TTFB gate catches server-side slowness independently of payload size.
http_req_waiting: ['p(99)<1000'],
// A blocked p99 > 50ms means connection pool exhaustion at the VU layer.
http_req_blocked: ['p(99)<50'],
http_req_failed: ['rate<0.01'],
// Gate on dropped iterations when using constant-arrival-rate.
dropped_iterations: ['count<10'],
},
};The http_req_blocked threshold catches a pathology that p95/p99 on http_req_duration can obscure: requests that spend the majority of their time waiting for a free socket at the client, not at the server.
Anti-patterns
| Anti-pattern | Why it misleads | Fix |
|---|---|---|
Reporting only p(95) in the summary | The 1-in-20 slowest requests are invisible. A p95 of 400ms with a p99 of 4000ms looks healthy. | Add p(99) and p(99.9) via summaryTrendStats. |
| Averaging across ramp-up and plateau | Low-load ramp-up samples dilute plateau tail. | Use separate scenarios or post-filter the JSON export by timestamp. |
Ignoring dropped_iterations | Under constant-arrival-rate, unreported requests make p99 look better than reality. | Always include dropped_iterations in the summary export check. |
| Treating avg as representative | A bimodal distribution has no typical request; avg falls between the two modes. | Use med (p50) as the central tendency; use tail ratio to confirm shape. |
| Comparing p99 across different VU counts | Higher concurrency changes the distribution; the numbers are not comparable. | Normalize by http_reqs rate (RPS) and note the executor type alongside any p99 number. |
References
Related skills
db-query-plan-analyzer
Reads `EXPLAIN` / `EXPLAIN ANALYZE` output from PostgreSQL, MySQL, or SQLite - identifies the dominant cost (sequential scan, nested loop, sort spill, missing index, type-cast preventing index use), proposes the specific index or query rewrite to fix it, and emits the candidate `CREATE INDEX` statement. Use when load testing or production telemetry shows the database as the bottleneck and the team needs targeted query-level remediation.
flame-graph-analyzer
Reads CPU flame-graph output from py-spy (Python), async-profiler (JVM), Go pprof, or Node.js `perf_hooks` / clinic.js: identifies the hot path (top sample-time frames), classifies the bottleneck (CPU-bound vs lock contention vs allocator pressure), and proposes the next investigation step. Use when a perf regression is bisected to a commit but the hot path inside it is unclear; for tail-latency percentiles use latency-percentile-analyzer, for GC pauses specifically use jvm-gc-tuning, and for a slow SQL hot path use db-query-plan-analyzer.
gatling-load-testing
Authors Gatling simulations in Java / Kotlin / Scala (or JS / TS) using the Simulation class plus http() / scenario() / exec() DSL builders, ramps virtual users via injectOpen (arrival rate) or injectClosed (concurrent count), runs via Maven / Gradle / sbt with the Gatling plugin, and gates CI on assertions defined in setUp(). Use when the project is on the JVM and the team prefers code-first load tests over JMeter's XML or k6's JavaScript-only authoring.
jmeter-load-testing
Authors Apache JMeter `.jmx` test plans (Thread Groups + HTTP samplers + assertions + listeners) in the JMeter GUI, runs them headlessly via `jmeter -n -t plan.jmx -l results.jtl`, generates an HTML dashboard with `-e -o`, and gates CI on JTL parsing. Use when the project has an existing JMeter investment, needs JVM-native load tooling, or works in domains with strong JMeter community support (banking, telecom, enterprise).
jvm-gc-tuning
Diagnoses JVM garbage-collection behaviour under load: reads and interprets unified GC logs (-Xlog:gc*), selects the right collector (G1 vs ZGC vs Parallel vs Serial), tunes heap sizing and pause-time targets, quantifies allocation rate, and traces the GC-pause-to-latency-tail link using GCViewer and Java Flight Recorder (JFR). Use when a load test reveals p99/p999 latency spikes that correlate with GC activity, or when heap sizing and collector selection need justification before a performance baseline is locked.
k6-load-testing
Authors k6 JavaScript load-test scripts (VU loops + checks + sleeps), configures the `options` block with `stages` (ramp-up patterns) and `thresholds` (p(95) latency, error rate), runs via `k6 run script.js` or `--vus / --duration` ad-hoc flags, and uses thresholds as the CI pass/fail signal. Use when the project ships HTTP / WebSocket / gRPC load tests and the team wants developer-friendly JavaScript authoring.
lighthouse-budget-author
Drafts a `lighthouserc.js` (or `budget.json`) at design time - picks Web Vitals thresholds (LCP / INP / CLS) per route based on traffic class (cached / dynamic / API-heavy / form-heavy) and the team's NFRs, plus resource-size budgets (JS / CSS / images / total bytes). Emits the config file ready for the lighthouse-perf runner. Use when starting Lighthouse coverage on a project that has no budgets yet, or when the existing budgets need a redesign.
lighthouse-perf
Configures Lighthouse CI (`@lhci/cli`) to audit Web Vitals (LCP, INP, CLS) on every PR, asserts against canonical thresholds (LCP ≤2.5s, INP ≤200ms, CLS ≤0.1 at the 75th percentile), uploads Lighthouse reports as build artifacts, and posts deltas as PR comments. Use when the project ships a web frontend and the team needs continuous Web Vitals monitoring tied to PR gating.
load-testing-overview
Teaches load and performance testing from zero: how to choose between k6, JMeter, Gatling, Locust, and Artillery based on observable project facts (team language, tests-as-code vs GUI authoring, protocols beyond HTTP, CI gating needs); the six load profiles (smoke, average-load, stress, spike, soak, breakpoint) and the question each one answers; the difference between open workload models that hold arrival rate constant and closed models that hold concurrent users constant; why percentiles rather than averages are the unit of measurement; and how to turn a run into a pass/fail CI gate, with a first runnable k6 script. Use when a service needs performance coverage and the tool, the load profile, or the pass/fail threshold has not been decided yet.
locust-load-testing
Authors Locust load tests as Python classes - HttpUser with @task-decorated methods plus on_start hooks and between() wait_time - runs via `locust -f locustfile.py` headless mode (or distributed via `--master` / `--worker`), and exports CSV / JUnit reports for CI gating. Use when the project's primary stack is Python and the team wants load tests in the same language as the application.
perf-budget-gate
Builds a unified release-readiness gate that aggregates verdicts from any combination of k6 / JMeter / Gatling / Locust load runners and Lighthouse CI Web Vitals, applies severity-aware pass/fail thresholds, and emits a single go / no-go decision with per-metric deltas vs the main-branch baseline. Posts the delta as a PR comment when the team has the integration set up. Use when authoring a CI step that gates a deployment on cross-runner perf compatibility.
slo-load-test-plan
Turns a service's SLOs and endpoint traffic mix into a named scenario matrix: one scenario per SLO boundary condition, a load profile (smoke, average-load, stress, soak, spike, breakpoint) per scenario, an open or closed workload injection model, a threshold expression derived from the SLO the scenario guards, and an error-budget calculation that sets the soak run's failure allowance. Stays runner-agnostic and fixes the pass/fail line before any tool is configured. Use when an SLO document and an endpoint list both exist but nobody has decided which load runs to make, what shape of load each carries, or what number would count as a failure.