By Lucas Yoris · Performate
Think Time and Concurrency: Making k6 Scenarios Feel Human (Without Fantasy Math)
Think time and concurrency in k6: realistic pacing, sleep distributions, and interaction with arrival-rate scenarios—aligned with product analytics.
Your k6 script hammers checkout every 50ms, but analytics says median session gap is 8 seconds. The server passes a fantasy test—and fails on launch day when real pacing hits connection pools and cart TTL logic differently.
Think time (sleep) models human or client pacing between requests. It interacts with executor choice: closed VU loops speed up when you remove sleep; open arrival-rate executors keep req/s steady while sleep affects how many VUs you need. In this guide you will learn how to derive sleeps from telemetry, pick distributions, and avoid math that looks realistic but is not.
Why think time changes concurrency—not just "slowing down the test"
Without pacing, VU-based scenarios behave like bots:
- Closed models (
constant-vus,ramping-vus): fewer sleeps → more iterations per VU → higher throughput for the same VU count. - Open models (
constant-arrival-rate): target req/s stays fixed; longer sleeps require more VUs to maintain the rate (executors). - Session realism: cart abandonment timers, token refresh windows, and UI debouncing assume gaps between calls.
- Downstream caches: zero sleep keeps caches hot unrealistically; prod users browse, compare, pause.
Think time is the tempo in music—same notes, wrong rhythm, different performance.
When zero sleep lies about pool sizing
Connection pools, server threads, and DB pools sized for "fast bots" saturate when real mobile clients add seconds between taps (mobile backend patterns). Pair sleeps with parameterized user data so not every VU follows identical timing.
Practical k6 implementation: distributions and arrival-rate interaction
Derive baseline sleep from analytics percentiles—median gap between API calls in a session trace—then add jitter.
Example script (illustrative—not a production-ready test). Fictional timings—replace with your telemetry.
What this example demonstrates:
- Log-normal-ish jitter:
randomBetweenaround an 8s median browse pause—not uniform 0–10s fantasy. - Shorter think time on checkout: reflects focused conversion flow vs catalog browsing.
- Arrival-rate executor: 15 req/s target with sleeps included—watch
maxVUsduring tuning. - Tagged routes: separate thresholds for browse vs checkout latency.
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE = __ENV.API_BASE || 'https://staging.example.com';
function thinkSeconds(median, spread) {
const min = Math.max(0.1, median - spread);
const max = median + spread;
return min + Math.random() * (max - min);
}
export const options = {
scenarios: {
browse_open: {
executor: 'constant-arrival-rate',
rate: 15,
timeUnit: '1s',
duration: '8m',
preAllocatedVUs: 25,
maxVUs: 120,
tags: { journey: 'browse' },
exec: 'browseFlow',
},
},
thresholds: {
'http_req_duration{route:catalog}': ['p(95)<450'],
'http_req_duration{route:cart}': ['p(95)<600'],
},
};
export function browseFlow() {
let res = http.get(`${BASE}/catalog`, { tags: { route: 'catalog' } });
check(res, { 'catalog ok': (r) => r.status >= 200 && r.status < 300 });
sleep(thinkSeconds(8, 3)); // ~5–11s browse pause from analytics
res = http.get(`${BASE}/items/SKU-42`, { tags: { route: 'item' } });
check(res, { 'item ok': (r) => r.status >= 200 && r.status < 300 });
sleep(thinkSeconds(4, 2));
res = http.post(`${BASE}/cart`, JSON.stringify({ sku: 'SKU-42', qty: 1 }), {
headers: { 'Content-Type': 'application/json' },
tags: { route: 'cart' },
});
check(res, { 'cart ok': (r) => r.status >= 200 && r.status < 300 });
sleep(thinkSeconds(2, 1)); // shorter checkout-adjacent pause
}
Patterns that work
- Import medians from product analytics or session replay tools—not guesses from unrelated apps.
- Different sleeps per journey phase inside
group()blocks for readable summaries. - Increase
maxVUswhen adding think time to arrival-rate scenarios until dropped iterations stay zero. - Document assumed pacing beside SLO results so stakeholders interpret throughput correctly.
Anti-patterns to avoid
- Uniform random 1–10s everywhere—real sessions skew toward longer tail pauses.
- Copying sleeps from a blog post about a different industry.
- Removing all sleeps to "save CI time" on tests marketed as production-realistic.
Pro tip (example command): watch VU count vs arrival rate while tuning sleep.
k6 run --summary-trend-stats="avg" browse-think-time.js
What this command demonstrates: summary stats help confirm whether added sleep forced k6 toward maxVUs—a sign you need more headroom or lower req/s.
Decision framework: how much think time to apply
| Situation | Recommended action |
|---|---|
| Public catalog browse | Median inter-request gap from analytics + jitter |
| Checkout / payment | Shorter sleeps; still non-zero for 3DS or review screens |
| Mobile BFF with background refresh | Model refresh interval separately (mobile patterns) |
| CI smoke only | Minimal sleep (0.1–0.5s)—label run as non-realistic pacing |
| API-only microservice (no UX) | Sleep between chained calls in multi-step flows only |
Use analytics-driven medians if product owns session telemetry and you need stakeholder trust.
Use fixed short sleeps if the test is a regression gate, not capacity planning.
Use zero sleep only if the explicit question is raw backend RPS without client pacing—and document that caveat.
Observability, documentation, and next steps
- Record sleep distribution assumptions in run README (median, spread, source dashboard).
- Compare VU count at equal arrival rate with and without sleeps—archive both.
- Validate think time against a sample of session traces quarterly.
- Tag journeys so latency thresholds align with paced vs unpaced routes.
- Re-tune after major UX changes (single-page app navigation shifts gaps).
How Performate simplifies think-time tuning
Visual scenario editing makes pacing experiments fast. Example workflow for the browse flow above:
- Import catalog, item, and cart requests from Postman. Problem solved: journey order is visible before adding delays.
- Set think time per request in the scenario editor—8s after catalog, 4s after item, 2s after cart. Problem solved: no hunting
sleep()lines across scripts. - Choose constant arrival rate at 15 req/s and run a short trial. Problem solved: see immediately if VUs approach max without reading executor docs.
- Adjust sleeps or max VUs in the UI when dropped iterations appear. Problem solved: pacing and capacity tuning in one place.
- Compare reports before/after sleep changes. Problem solved: prove to product that realistic pacing shifts pool saturation.
- Export k6 with sleeps preserved for CI—labeled as realistic or smoke in scenario tags. Problem solved: same pacing in pipeline and desktop runs.
Closing takeaway
Think time is part of the workload model, not a cosmetic delay. Derive sleeps from telemetry, align them with executor choice, and document assumptions so green tests mean realistic concurrency—not bot swarms.
Pull one session trace from analytics this week, measure the gap between catalog and cart calls, and update a single k6 sleep before the next staging run.
Ready to optimize your API performance?
Use Performate to turn this playbook into runnable k6 scenarios, thresholds, and shareable reports without losing days to glue code.