HighX
Reference

Load testing

Measure a deployment under load, and fail a release that got slower.

bun run load drives a deployed HighX application over its real transport — HTTP for mutations, a WebSocket for subscriptions — and reports what it costs. Given a recorded baseline it exits non-zero when a case regresses, so a release has a gate rather than an opinion.

It is separate from bun run test on purpose: it needs a deployment, takes minutes rather than seconds, and costs real requests. A suite that runs on every save must do none of those.

Enabling it on a target

The load routes do not exist unless the deployment sets a secret. An environment that never sets one has nothing to reason about — the routes return 404, not 403.

openssl rand -hex 24 | highx secret put LOAD_TEST_SECRET

Unset LOAD_TEST_SECRET on anything serving real users. A holder of that secret can create and erase tenants. It is a test affordance, not something production should carry.

Then, from the repository root:

LOAD_TEST_SECRET=<the same value> \
  bun run load --target https://your-worker.workers.dev

Using it as a release gate

# Record what you currently trust.
bun run load --target https://your-worker.workers.dev --update-baseline

# Later, before shipping.
bun run load --target https://your-worker.workers.dev || echo "do not ship"

The baseline is committed, so a regression shows up in review as a changed file rather than as an argument about whether things feel slower.

Options

optionmeaning
--target <url>The deployment to drive. Required; there is no default.
--secret <value>Defaults to $LOAD_TEST_SECRET.
--scale <n>Multiplies every scenario's work.
--soak <seconds>Sustained traffic reporting latency drift. Off by default.
--idle <seconds>Idle a tenant this long before measuring its wake cost.
--tolerance <n>Regression allowed before failing. Default 0.25.
--json <path>Write the full result alongside the report.
--no-compareReport only, never fail.
--update-baselineRecord this run instead of comparing.

What it measures

scenarioquestion it answers
Write latencyWhat does one mutation cost a user?
Write throughput under concurrencyWhere does concurrency stop helping inside one tenant?
End-to-end fan-outHow long until the slowest subscriber has the change?
Reconnect stormWhat does a deploy cost, across every open browser at once?
Read latency as rows accumulateWhat does an unbounded read cost as a tenant grows?
Cross-tenant concurrencyDo tenants actually scale independently?
Waking an idle tenantWhat does a tenant used once a day cost?
SoakDoes latency drift over sustained traffic?

Cross-tenant concurrency is the one to watch

Each tenant is its own Durable Object, so this is the only scenario that separates work which genuinely parallelizes from work that merely queued behind one object. Anything shared — the Worker, session storage, D1 — shows up here and nowhere else.

Flat or falling per-write cost as tenants increase means tenants are independent. A rising curve means something is contended, and this is the number to re-check first if the architecture ever grows a shared component.

Reading the numbers honestly

They are dominated by network round trip. A run from a laptop pays most of its milliseconds before any server work happens. What moves when your code moves is the difference between rows, not the absolute figures.

A baseline is only comparable to runs from a similar place. Recording one on a laptop and gating in CI will report a different network path as a regression. Record the baseline where the gate will run. Absolute latencies have been observed to double between sessions on identical code, purely from network conditions — every relative relationship held.

Tolerance defaults to 25% on p50. Run-to-run variation on an unchanged build is roughly ±10%, so 25% leaves room without hiding a real regression. p50 rather than p95, because the tail belongs to the internet.

Drift is the soak's signal, not its average. A leak shows as an ending worse than its beginning; an average hides exactly that. Some upward drift is honest — rows accumulate during the run, so an unbounded read really is getting bigger.

Wake cost is an upper bound. Whether the platform has actually evicted an idle object is its decision, not something a client can force.

Soak is not baselined. Its numbers depend on how long you ran it, so comparing them would compare durations rather than builds.

Isolation

Every run works in its own tenant and erases it afterwards, including tenants opened by individual scenarios and including runs that fail partway. Without that, rows accumulate and the unbounded read gets slower every run — a regression that is really just history.

What it does not do

  • No multi-hour soak. --soak runs as long as you ask, but nothing has been left running for days, so leaks on that timescale are unproven.
  • No geographic distribution. Every client originates where the run does.
  • No forced eviction. Wake cost is bounded, not isolated.

See limits for the figures a run produced.

On this page