Guides · Concurrency

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.

A terminal dashboard: throughput in requests per second, success/failure counts, p50/p90/p99 latency, a status-code breakdown, and an RPS sparkline
klainload's live dashboard — throughput, success rate, latency percentiles, a status-code breakdown, and an RPS sparkline, redrawn as the agents drive requests at the target. Press s at any time to stop.

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 3

Split by concern

The tool is a handful of small modules, each a real concern — a working demonstration of the module system:

  • config.ts — the Config shape and CLI parsing.
  • engine.ts — the agent goroutines and the stop wiring.
  • stats.ts — the per-request Result and 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 no await. Stopping is a channel close(), not a cancelled Promise.
  • A synchronous request is what a closed-loop generator wants.XMLHttpRequest with async: 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; GOMAXPROCS sets the parallelism and KLAINSYNC_STACK_KB the 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.