Build a concurrent load tester
A load tester is the perfect shape for klain:sync: a pool of goroutine "agents" hammering a real endpoint in parallel, a live view of throughput and latency, and a report you keep. We'll build klainload — point it at any URL, set how many agents and for how long (or run until you stop it), watch a klain:tui dashboard, and get a summary that stays on screen. One native binary, no runtime, no event loop.

1 · The agents
Each agent is a goroutine running a closed loop: check a shared stop channel (without blocking), fire one synchronous request at the target, and stream a Result out. There's no fixed request count and no work queue — a load tester runs for a duration or until stopped, as fast as the target answers:
go(() => {
for (;;) {
// Non-blocking stop check: once stop is closed, its recvCase fires
// for every agent, so they all halt together.
let stopped = false
select(
stop.recvCase((_: number) => { stopped = true }),
defaultCase(() => {}),
)
if (stopped) break
const t0 = performance.now()
const xhr = new XMLHttpRequest()
xhr.open(method, url, false) // async: false — a real blocking request
xhr.send()
const latencyUs = Math.round((performance.now() - t0) * 1000)
// status is 0 on a connection failure; send() never throws.
results.send({ latencyUs, status: xhr.status, bytes: xhr.responseText.length })
}
done.send(1)
})xhr.open(method, url, false) is a real blocking HTTP request; on a goroutine it parks that agent while the transfer is in flight, so all the agents' requests genuinely overlap. go(fn) puts each agent on the M:N scheduler — with GOMAXPROCS defaulting to your core count they run in parallel across cores.
2 · Stopping: a broadcast close
This is the Go idiom that makes "stop at any time" trivial. Closing a channel wakes every receiver, and in a select a recvCase on a closed channel fires immediately — so a single stop.close() halts all the agents at once. Never send on it; only close it:
const stop = new Channel<number>(0)
// From the collector: close once — every agent's recvCase fires.
if (!cfg.infinite && now >= deadline) stop.close() // duration reached
if (key === 's' || key === 'q') stop.close() // manual stop The same close ends a fixed-duration run (fired at a performance.now() deadline) and an infinite one (fired by a keypress). Workers never touch results after stopping; the collector drains what's left with a non-blocking select.
3 · Fan in: fold results into stats
The collector runs on the main thread. Each pass drains every result currently available (non-blocking select with a defaultCase), then — in the live UI — polls the keyboard for the stop key and repaints. readKey(60) both paces the loop and reads a keystroke if one is waiting:
while (finished < cfg.concurrency) {
// Drain every result currently available, without blocking.
let draining = true
while (draining) {
select(
results.recvCase((r: Result) => { record(stats, r) }),
done.recvCase((_: number) => { finished += 1 }),
defaultCase(() => { draining = false }),
)
}
const key: string = readKey(60) // pace ~60ms AND poll for a stop key
if (key === 's' || key === 'q') { stop.close() }
if (now - lastRender > 100) render(renderRunning(cfg, stats, now - start))
} A Channel<Result> carries the whole per-request object by pointer, so one pass folds latency, HTTP status class, and byte count together. Latencies land in a log-bucketed histogram — all you need to read percentiles off the cumulative counts without storing every sample:
// The pth-percentile latency, read off the cumulative histogram.
export function pct(s: Stats, p: number): number {
const target = p * s.completed
let cum = 0
for (let b = 0; b < NBUCKETS; b++) {
cum += s.hist[b]
if (cum >= target) return 1 << b // 2^b µs — the bucket's lower edge
}
return s.maxUs
}4 · Configuring the run
The target and parameters come from the command line or the first TUI screen. With no URL, a config form opens (edit the URL, method, agent count, duration or infinite, headers, body, then Start); a URL pre-fills it, and --run skips it:
klainload # opens the config screen
klainload http://host/path # pre-fills the config screen
klainload http://host/path --run -c 32 -d 30 # skip it: 30s, 32 agents
klainload http://host/path -d inf # run until you press s The config screen is a small component built the same way as the klain:tui form guide — you own the field values and route each keystroke; TextInput just draws the text and a cursor.
5 · The results that stay
When the run ends, the results screen shows the success rate and latency summary, and the same report is printed to stdout after the TUI exits — so it survives in your scrollback instead of vanishing with the alt-screen. Non-interactive runs (piped, or in CI) skip the TUI entirely and just print it:
klainload — GET http://127.0.0.1:8080/
16 agents · 30s
Summary
requests 482913
throughput 16097 req/s
success 482910 (100.0%)
failed 3 (0.0%)
Latency
mean 980µs min 210µs max 44.1ms
p50 0.8ms p90 1.6ms p99 4.1ms
Status codes
2xx 482910 3xx 0 4xx 0 5xx 0 errors 3Split by concern
The tool is a handful of small modules, each a real concern — a working demonstration of the module system:
config.ts— theConfigshape and CLI parsing.engine.ts— the agent goroutines and the stop wiring.stats.ts— the per-requestResultand the aggregation.report.ts— the persistent text summary.screens.ts— the live dashboard and results screen.configform.ts— the interactive config screen.load_test.ts— the orchestrator that wires them together.
Good to know
- Goroutines are a separate world from
async. An agent body is synchronous — channel ops and the blocking request park the goroutine, not the thread, and there is noawait. Stopping is a channelclose(), not a cancelled Promise. - A synchronous request is what a closed-loop generator wants.
XMLHttpRequestwithasync: false(a real, faithful web API) blocks the agent until the response is in, and yields cooperatively on a goroutine so the agents overlap. - It's all tunable. Agent count and duration are parameters;
GOMAXPROCSsets the parallelism andKLAINSYNC_STACK_KBthe per-goroutine stack — dial the load and watch the throughput and the tail move. - Colours are compile-time literals in
klain:tui, so the helpers branch on a colour key rather than passing one through a variable.