Load Testing
Glubean runs load tests — concurrent, sustained traffic against an API to measure latency percentiles, throughput, and error rate under pressure — as a first-class primitive alongside test, contract, and workflow. Load plans live in *.load.ts files, import from @glubean/sdk/load, run with glubean load, and upload as kind=load runs that produce a rich load artifact (per-endpoint/step breakdown, latency distribution, timeline, threshold pass/fail, bounded failure samples).
This is distinct from recording a single-request metric inside a functional test. Load testing measures aggregate behavior under concurrency, not one request in isolation.
When to reach for it
Write a load test when you’re asking about performance under load, capacity, throughput/RPS, p95/p99 latency, “can this handle N concurrent users,” soak/stress behavior, or gating a performance SLO in CI. Load is opt-in — it generates real concurrent traffic, so don’t add it as part of routine functional/contract coverage.
Define a load plan
A load plan is a scenario (the workload, reused across runs) plus a runner (concurrency, duration, thresholds). Import load authoring APIs from @glubean/sdk/load:
import { loadScenario, loadRunner, feeder } from "@glubean/sdk/load";
// 1. Scenario — the steps each virtual user repeats. Steps run in order;
// each step's requests are attributed to its endpoint + step in the report.
const shopScenario = loadScenario<{ q: string }>("shop")
.step("browse", async (ctx) => {
const res = await ctx.http.get("load/catalog", { searchParams: { q: ctx.input.q } });
ctx.expect(res).toHaveStatus(200);
})
.step("checkout", async (ctx) => {
const res = await ctx.http.post("load/checkout", { json: { items: [{ sku: "A1", qty: 1 }] } });
ctx.expect(res).toHaveStatus(201);
});
// 2. Runner — concurrency + duration + thresholds. Export it so the CLI finds it.
export const shopLoad = loadRunner("shop-load", {
scenario: shopScenario,
concurrency: 25, // virtual users in flight
duration: "20s", // "60s" / "2m" / ms — total run length
rampUp: "3s", // optional: ramp concurrency up over this window
feeders: { term: feeder.fromArray([{ q: "pen" }, { q: "notebook" }], { key: "q" }).roundRobin() },
input: ({ feed }) => ({ q: (feed.term as { q: string }).q }), // per-iteration input
thresholds: {
transaction: { errorRate: "<5%", p95: "<400ms" }, // whole-scenario gate
endpoints: {
"GET /load/catalog": { p95: "<120ms", p99: "<400ms", errorRate: "<0.5%" },
"POST /load/checkout": { p95: "<180ms", errorRate: "<4%" },
},
},
report: { failureTraces: 10, slowTransactionSummaries: 10 }, // bounded sample caps
});LoadContext
A load step’s ctx is a LoadContext, not the full TestContext from a functional test — some APIs don’t make sense under concurrency.
Kept: http, vars, secrets, session, expect, assert, skip, fail, log, warn, pollUntil, setTimeout.
Added for load: input (per-iteration input from feeders), iteration ({ id, index }), producerSlot ({ id, index }), now(), report (checkpoint / primaryComplete), and metrics (custom metric handles declared on the runner).
Removed: validate, trace, metric, action, event, getMemoryUsage, retryCount — these don’t fit concurrent load. Use a functional test if you need ctx.metric() for a custom business metric.
Config keys
| Key | Meaning |
|---|---|
concurrency | Virtual users in flight |
duration | Total run length ("60s", "2m", or ms) |
iterations | Alternative to duration: fixed iteration count |
rampUp | Ramp concurrency up over this window |
feeders | Data providers (feeder.fromArray/fromCsv/..., .roundRobin() / .random()) |
input | ({ feed, iteration, producerSlot }) => sceneInput per iteration |
pacing.thinkTime | Delay between steps ("100ms" or a distribution) |
thresholds | Pass/fail gates (see below) |
metrics | Custom metric declarations (rate, trend, counter) exposed as ctx.metrics.* |
continuation | Backlog controls for async continuation workloads after ctx.report.primaryComplete() |
report | Bounded sample caps: failureTraces, slowTransactionSummaries |
assertions.onFailure | "continue" | "skipRemainingSteps" | "abortIteration" |
abort | "precise" (default) | "coarse" — how a run abort reaches in-flight requests |
Thresholds — the pass/fail gate
Thresholds turn a load run green or red, and are what gates CI. They apply at three scopes:
thresholds: {
transaction: { errorRate: "<1%", p95: "<2000ms", p99: "<4000ms", throughputPerSec: ">500" },
endpoints: { "GET /api/foo": { p95: "<200ms", errorRate: "<0.5%" } },
steps: { "checkout": { p95: "<800ms" } },
}Expressions are comparison strings: "<400ms", "<5%", ">500". Each evaluated threshold lands in the artifact’s summary.thresholds[] with its actual value and pass verdict.
A threshold-failed load run is still valid data. Failing a p95 gate turns the run red, but the numbers it measured are real and comparable — Performance reads it like any other completed run.
Stable endpoint keys
Performance and Benchmarks group load evidence by endpoint key. Static paths are easy, but dynamic load URLs can otherwise fragment into many literal endpoints.
When a load step hits a templated endpoint, pass the canonical route through the
non-wire context.glubeanRoute option:
const itemScenario = loadScenario<{ id: string }>("item")
.step("get item", async (ctx) => {
const res = await ctx.http.get(`items/${ctx.input.id}`, {
context: { glubeanRoute: "GET /items/:id" },
});
ctx.expect(res).toHaveStatus(200);
});The route key is used only by Glubean reporting; it is not sent to the system under test. Contracts add this metadata automatically. Load scenarios are raw runtime workloads, so declare it when you want clean per-endpoint comparisons.
Custom metrics
Use load custom metrics when the SLA is not only HTTP latency. Declare metrics
on the runner, record them in scenario steps through ctx.metrics, and gate
them with thresholds.customMetric:
import { counter, loadRunner, rate, trend } from "@glubean/sdk/load";
export const asyncJobLoad = loadRunner("async-job", {
scenario: asyncJobScenario,
concurrency: 50,
duration: "60s",
metrics: {
pollOk: rate(),
e2eLatency: trend({ unit: "ms" }),
retries: counter(),
},
thresholds: {
customMetric: {
pollOk: { rate: ">99%" },
e2eLatency: { p95: "<2000ms" },
retries: { sum: "<100" },
},
},
});Inside the scenario:
ctx.metrics.pollOk.add(true);
ctx.metrics.e2eLatency.add(ctx.now() - startedAt);
ctx.metrics.retries.add();Custom metric summaries are folded into the load artifact and appear alongside ordinary p95, throughput, and error-rate evidence.
Traffic mix & table expansion
Run multiple weighted scenarios in one plan:
import { loadMixEntry } from "@glubean/sdk/load";
export const mixedLoad = loadRunner("mixed", {
scenarios: [
loadMixEntry({ id: "checkout", scenario: checkoutScenario, weight: 70 }),
loadMixEntry({ id: "refund", scenario: refundScenario, weight: 30 }),
],
concurrency: 300,
duration: "60s",
});Or expand one plan per data row (e.g. per region):
export const regionLoad = loadRunner.each(REGIONS)("region-{region}", (row) => ({
scenario: scenarioForRegion(row.region),
concurrency: 100,
duration: "30s",
}));Run & upload
glubean load # Discover and run *.load.ts under the project
glubean load tests/load/shop.load.ts # Run one plan ad hoc
glubean load tests/load/ # Run every *.load.ts under a directory
glubean load tests/load/ --upload # Upload each completed load artifact
glubean load tests/load/ --upload-target tgt_abc # Override the upload targetglubean load selects plans by explicit target (file, dir, or glob). It
does not resolve glubean.yaml profiles for load selection in the current
CLI. The loadRunner(...) owns virtual-user concurrency, duration, traffic mix,
thresholds, and the emitted LoadArtifact; the CLI flags own environment and
upload destination.
For repeated local or CI use, create a script with the exact command you want:
{
"scripts": {
"load:perf": "glubean load tests/load --env-file .env.staging --upload"
}
}See the CLI Reference for command flags and Upload Your First Run for auth/upload setup.
What a load run produces
A load run’s artifact (schemaVersion: "glubean.load.v1") is what the Cloud dashboard renders:
summary—pass(threshold verdict),totalIterations,successfulIterations,failedIterations,errorRate,throughputPerSec,latency({ p50, p90, p95, p99, max }ms),latencyDistribution[](fixed-ladder histogram),thresholds[](each{ scope, metric, expression, actual, pass }),advisories[].endpoints[]— per endpoint (routeKey,method):requestCount,errorRate,latency,throughputPerSec,latencyDistribution,statusCounts.steps[]— per scenario step:invocationCount,errorRate,latency.scenarios[]— per scenario aggregate.matrix[]— scenario-step × endpoint (which endpoint each step hit).timeline— fixed-width windows over the run:{ offsetMs, requests, errors, throughputPerSec, latency, iterations, peakInFlight }.summary.customMetrics[]— foldedrate,trend, andcountermetrics when declared.samples— boundedfailureTraces[](failed iterations) andslowTransactions[](slow-but-passing), capped byreport.
These surface in the target’s Performance tab (capacity, trend, thresholds, and run-vs-run comparison), the Runs tab (per-run detail: config, KPI cards, over-time timeline, latency histogram, per-endpoint table, transaction breakdown, threshold table, samples), and Benchmarks (cross-source comparison).
Performance vs Benchmarks
Both Cloud surfaces read the same uploaded kind=load evidence. The difference
is the comparison boundary:
| Surface | Boundary | Good question |
|---|---|---|
| Performance | One Target, one implementation/deployment. | ”Did this target regress, saturate, or violate its thresholds over time?” |
| Benchmarks | Several source Targets sharing one loadRunner id. | ”Which implementation wins under the same authored workload?” |
Use Performance for drift, baselines, capacity, and run A vs run B inside one target. Use Benchmarks when the source itself changes: Express vs Bun, two deployments, or several services that implement the same API.
Benchmarks in Cloud
The loadRunner id is also the anchor plan id for Cloud Benchmarks. If you want to compare several implementations of the same API, upload load runs from separate source targets using the same authored runner id:
export const shopLoad = loadRunner("shop-load", {
scenario: shopScenario,
concurrency: Number(process.env.LOAD_CONCURRENCY ?? 500),
duration: "60s",
});Then create a benchmark in Cloud with anchor plan shop-load and attach the source targets, such as Express API and Bun API.
For cleaner benchmark deltas, keep the load shape consistent across sources: same runner id, same duration or iteration bound, same scenario mix, and comparable environment labels. Cloud still renders warned comparisons, but it surfaces the warning and the compared run configs instead of silently hiding or equating them.
Best practices
- Start small. Default to modest
concurrencyand a shortduration(e.g. 25 virtual users /"20s") and scale up from there — don’t open with 1000 VUs. - Always set thresholds so the run has a pass/fail verdict and can gate CI. Base them on your SLOs, or start conservative and tighten over time.
- Reuse functional knowledge. A load scenario makes the same
httpcalls as a functional test, just without per-request assertions beyond status/shape. - Don’t call
ctx.metric()in a load step — it isn’t available there. For a custom business metric, use a functional test instead.
Next
- Performance — read one target over time: trend, capacity, thresholds, run comparison
- Benchmarks — compare several source targets under the same load plan
- CLI Reference —
glubean loadcommand and flags - Upload to Cloud — token, project, and target setup