LoadWave
Reference

Configuration

Every field of the LoadWave YAML format, with its default and the reason it exists.

A LoadWave test is a YAML file. Every field is listed here, with its default and the reason it exists.

Unknown fields are rejected, not ignored. A misspelled key in a load test is an expensive kind of bug: the run appears to work and quietly measures something other than what you asked for.

Check a file without running it:

loadwave validate test.yaml

Top level

name: storefront-checkout
baseURL: https://staging.example.com
workersPerAgent: 4
betweenRequests: 1s-3s

load: { ... }
http: { ... }
tags: { ... }
thresholds: [ ... ]
scenarios: [ ... ]
FieldTypeDefaultMeaning
namestringloadwaveIdentifies the run in the dashboard and in result files.
baseURLstringPrefixed to every relative request path. Must be absolute.
workersPerAgentintcores − 1Worker processes each agent spawns. See below.
betweenRequestsduration or range1sPause after every request, whatever its outcome. See below.
loadobjectrequiredThe shape of the load over time.
httpobject{}HTTP client settings.
tagsmap{}Labels attached to every metric the run produces.
thresholdslist[]Pass/fail assertions.
scenarioslistall registeredWhat to run.

workersPerAgent

Virtual users are goroutines, and past a few thousand of them one Go runtime's scheduler and garbage collector — not the system under test — become the bottleneck. Spreading the pool across processes buys that headroom back, and means a scenario that panics cannot take the whole generator down.

One per core, less one, is a reasonable default; the spare core leaves room for supervision and metric reporting. Raising it past the core count does not help.

betweenRequests

A pause inserted after every request, whatever its outcome. This is the run's pacing floor, and the default is one second.

betweenRequests: 1s        # the default
betweenRequests: 500ms-2s  # jittered, which is usually better
betweenRequests: "0"       # none — flat out

It is deliberately not zero by default. Without it:

  • a scenario with no think time of its own loops as fast as the network allows;
  • a scenario whose request fails instantly — a refused connection, a 500 from a cache — loops as fast as the CPU allows.

The second is the dangerous one. It is how a load test turns into an accidental denial of service against a service that has already fallen over, and it is why the pause applies on the failure path too rather than only after a successful response.

Because it applies after the last request of an iteration as well, it also paces the gap between iterations. A scenario's own think steps are additional to it.

Prefer a range. Identical pauses make every virtual user march in lockstep, producing traffic in synchronised bursts rather than the smooth arrival pattern a real population generates — and the bursts are what your service ends up being measured against.

Set it to "0" for a throughput test, where flat out is the point. Note the quotes: unquoted 0 is a number, and this field is a duration string.

Individual steps override it, including down to none:

steps:
  - get: /api/products
  - get: /api/products/${id}
    betweenRequests: 200ms # quicker after this one
  - post: /api/orders
    betweenRequests: "0" # straight on to the next

Pacing is excluded from iteration_duration, exactly as think is: it is not work, and counting it would make every iteration look a second slower than it is. It is also interruptible, so a stopping run does not sit through everyone's pause.

loadwave validate prints the effective value, so it is never left implicit.

load

constant-vus

Holds a fixed number of virtual users. Needs either a duration or an iterations budget — without one the run would never end, which is rejected.

load:
  executor: constant-vus
  vus: 50
  duration: 5m

ramping-vus

Moves linearly between stage targets. Every profile starts from zero, so the first stage ramps up rather than starting at its own target.

load:
  executor: ramping-vus
  stages:
    - { duration: 30s, target: 100 } # 0 → 100
    - { duration: 5m, target: 100 } # hold
    - { duration: 2m, target: 500 } # push past it, looking for the knee
    - { duration: 30s, target: 0 } # ramp down
FieldTypeDefaultMeaning
executorstringconstant-vusconstant-vus or ramping-vus.
vusint1Virtual users to hold. constant-vus only.
durationdurationHow long to hold. constant-vus only.
stageslistLegs of the ramp. ramping-vus only.
iterationsint0Stop after this many iterations in total, across every node. Zero means the duration governs.
maxIterationRateint0Cap on iterations started per second, fleet-wide. Zero leaves the VU count as the only control.
gracefulStopduration30sHow long in-flight iterations get to finish when the run stops.

Durations are Go duration strings: 500ms, 30s, 5m, 1h30m.

maxIterationRate throttles iterations, not requests

A scenario issuing five requests per iteration at maxIterationRate: 100 produces roughly 500 requests per second. This is the arrival rate a closed-model run would otherwise leave implicit — useful when you want to hold throughput steady rather than concurrency.

gracefulStop

When a run ends, virtual users are asked to finish their current iteration before exiting. Without this, stopping manufactures a cliff of cancelled requests that then appear as failures in the very results you are about to read. Past the budget, they are cancelled anyway — a scenario that ignores its context must not be able to hold the run open forever.

http

http:
  timeout: 20s
  headers:
    Accept: application/json
    Authorization: Bearer ${TOKEN}
  followRedirects: false
  insecureSkipTLSVerify: true
FieldTypeDefaultMeaning
timeoutduration30sBounds a whole request, body included.
headersmap{}Sent with every request. Per-step headers win.
userAgentstringGo's defaultOverrides User-Agent.
insecureSkipTLSVerifyboolfalseSkip certificate validation.
maxIdleConnsPerHostint512Pooled idle connections per host.
disableKeepAlivesboolfalseFresh connection per request, so setup cost is measured too.
disableCompressionboolfalseStop requesting gzip.
followRedirectsboolfalseFollow 3xx responses.
maxRedirectsint10Redirect depth cap.
isolatePerVUboolfalseGive every VU its own connection pool.
discardBodyboolfalseStream response bodies to nowhere. Bytes are still counted.
maxBodyBytesint4194304How much of a body to buffer.
proxystringenvironmentProxy URL.

Three defaults worth knowing about

maxIdleConnsPerHost defaults to 512, not Go's 2. Go's default throttles a load test to a trickle of connection churn, so it ends up measuring the client rather than the server. This is the single most common way a hand-rolled Go load test produces numbers that are quietly wrong.

followRedirects is off. A load test usually wants to measure the redirect itself rather than silently follow it and attribute the cost to the first request.

isolatePerVU is off. Sharing one pool is far cheaper. Turn it on when you need each virtual user to behave like a genuinely separate client — at the cost of a file descriptor per VU per host, which will hit ulimits at high VU counts.

scenarios

An entry with steps is defined here. An entry with only a name refers to a scenario compiled into the binary with the Go SDK. Leaving the list out runs everything the binary has registered.

If there are no scenarios anywhere — no steps, nothing compiled in — but a baseURL is set, LoadWave synthesises a single GET of that URL's path. That is what makes the one-liner smoke test work:

loadwave run --url https://example.com/health --vus 50 --duration 30s
scenarios:
  - name: browse
    weight: 3 # runs three times as often as a weight-1 scenario
    description: A visitor looking around.
    vars:
      category: electronics
    steps: [ ... ]

  - name: checkout # defined in Go
    weight: 1

A virtual user is assigned to one scenario for its whole life, not re-drawn each iteration. That is what makes per-user state coherent: a user who logged in during OnVUStart stays logged in.

Steps

Each step is either a request or a pause.

steps:
  - name: list products # metric label; derived if omitted
    get: /api/products # or post/put/patch/delete/head, or method + url
    headers: { X-Request-Id: "${__uuid}" }
    query: { page: "1" }
    json: { filter: "${category}" } # or `form`, or raw `body`
    expect: [200] # acceptable statuses
    timeout: 10s # overrides the run-wide timeout
    capture:
      productId: items.0.id # pull a value out of the JSON response

  - think: 1s-3s # a pause, drawn uniformly from the range
FieldMeaning
nameMetric label. Derived from the method and path if omitted, with variable-looking segments collapsed to *.
get / post / put / patch / delete / headShorthand setting both method and URL.
method, urlThe explicit form. Use one or the other, not both.
headers, queryMerged over the run-wide settings. Values are templated.
json, form, bodyThe request body. At most one. Templated at any depth.
expectAcceptable status codes. Anything else fails the step's check and ends the iteration.
captureVariables extracted from the JSON response.
timeoutPer-step override.
betweenRequestsPer-step pacing override, including "0" for none.
thinkPause instead of requesting. 2s, or a range like 1s-3s.

Always jitter your think times

think: 1s-3s, not think: 2s. Constant think times make virtual users march in lockstep and produce artificial traffic spikes that no real population would.

capture paths

Field access and array indexing, in either dotted or bracketed form:

capture:
  id: id
  token: $.data.token
  firstSku: items.0.sku
  alsoFirstSku: items[0].sku

This is deliberately not JSONPath. Filters and recursive descent would be a query language to document, test and explain, in exchange for capabilities a load test almost never wants.

Templating

${name} interpolates in URLs, headers, query values and bodies. Unknown names render as empty strings, so a capture that did not fire produces a request that visibly misses rather than an iteration that dies before making one.

Built-ins, all prefixed so they cannot collide with your own variables:

VariableValue
${__vu}The virtual user's run-wide unique id.
${__iteration}Zero-based iteration number.
${__shard} / ${__shards}This node's data partition, and the total count.
${__random}A random integer from this VU's own generator.
${__uuid}A random v4 UUID.
${__timestamp}Now, RFC 3339.
${__unixMilli}Now, epoch milliseconds.

${__vu} is how you give each virtual user its own account without two nodes colliding — ids are unique across the whole fleet, not just the process.

thresholds

A run that breaches any threshold exits 2. That is what makes LoadWave usable as a CI gate.

thresholds:
  - { metric: http_req_duration, stat: p95, op: "<", value: 500 }
  - { metric: http_req_failed, stat: rate, op: "<", value: 0.01 }
  - { metric: checks, stat: rate, op: ">", value: 0.99 }
  - { metric: http_req_failed, stat: rate, op: "<", value: 0.25, abortOnFail: true }
FieldMeaning
metricAny metric name, built-in or custom.
statcount, rate, avg, min, max, p50, p90, p95, p99, p999.
op<, <=, >, >=.
valueMilliseconds for durations; a fraction of one for rates.
abortOnFailStop the run immediately on breach rather than only failing at the end.

Thresholds are evaluated against whole-run aggregates, because that is the question a CI gate is asking. A breach latches: a p95 that recovers by the end of the run still breached, and a gate looking only at the final instant would let a real regression through.

A threshold on a metric that was never produced is reported as not measured, not as a pass. "We never measured it" and "it was fine" are very different answers to hand back to a pipeline.

abortOnFail is for conditions where finishing teaches you nothing — an error rate of 25% means the environment is broken, not slow.

More on this in Thresholds.

Overriding from the command line

Every field with an obvious flag has one, and flags win over the file. Useful for varying one number per environment without editing anything:

loadwave run test.yaml --vus 500 --duration 10m
loadwave run test.yaml --tag env=prod --threshold 'http_req_duration:p99<1000'
loadwave run --url https://example.com --vus 50 --duration 30s   # no file at all
--url, --name, --vus, --duration, --stages, --iterations, --rate,
--workers, --graceful-stop, --tag, --scenario, --threshold,
--insecure, --header, --timeout

Two more control the output rather than the run:

--out results.json      the whole snapshot, for scripting
--report results.html   a self-contained report with charts, for people

Pacing has a flag too, which is the quickest way to turn a smoke test into a throughput test:

loadwave run test.yaml --between-requests 0
loadwave run test.yaml --between-requests 200ms-1s

--stages takes duration:target pairs: --stages 30s:100,5m:100,30s:0.

--threshold takes metric:stat<value: --threshold 'http_req_duration:p95<500'.

The full flag list is in the CLI reference.

See also

On this page