Core
WebSocket load testing
perfscale drives WebSocket endpoints under load with the native step engine: open connections, stream messages from templates, wait for matching replies, and measure both the handshake and the application-level message round trip alongside your HTTP/TCP/UDP metrics.
This page is the guide — what the pieces are and when to reach for which.
Per-step parameters, outputs, and error semantics live in the
actions reference;
a runnable scenario ships as
examples/websocket.test.yaml.
Two styles
- One-shot session (
std/ws@v1) — connect, exchange, close in one step. The whole session is timed as one latency sample, like a FIX session. Simplest; right for request/reply-shaped exchanges. - Live connection (
std/ws-connect@v1+ friends) — a connection held across steps within an iteration, addressed by the id the connect step returns. Interleave WS traffic with other steps: subscribe over WS, trigger via REST, assert the push arrives.
Freely mixable in one scenario.
One-shot session
steps:
- name: subscribe and await first trade
use: std/ws@v1
with:
url: wss://stream.example.com/feed
messages:
- send: '{"op":"subscribe","channel":"trades","id":"sub-${seq}"}'
until_json: { type: trade }
check:
message_matches: { type: trade }
Each messages entry sends a payload; an entry with an until_* rule waits
for its matching reply before the next entry — and yields one message RTT
sample. The step fails on handshake/transport errors or on any entry whose
rule did not match in time. Full parameter list:
std/ws@v1.
Live connection
# config.yaml — 25 concurrent VUs, each holding its own connection per iteration
vus: 25
duration: 5m
# test.yaml
steps:
- name: open feed
use: std/ws-connect@v1
with: { url: "wss://stream.example.com/feed" }
outputs: feed
- name: subscribe
use: std/ws-send@v1
with:
id: "${{ feed.id }}"
send: '{"op":"subscribe","id":"sub-${seq}"}'
- name: await confirmation
use: std/ws-recv@v1
with:
id: "${{ feed.id }}"
until_json: { type: subscribed }
outputs: got
- name: hang up
use: std/ws-close@v1
with: { id: "${{ feed.id }}" }
Five steps share the connection:
std/ws-connect@v1— opens it, returns{ id, subprotocol, … }.std/ws-send@v1— sends a text template (${…}tokens expand per send) or binarysend_base64;repeat+interval_msstream N messages from one template.std/ws-recv@v1— reads until a stopping rule:until_contains(substring),until_json(JSON-subset match), or plaincount. Not reaching it withintimeoutfails the step.std/ws-ping@v1— transport ping→pong; RTT in the step'sduration_ms.std/ws-close@v1— graceful close handshake.
Connection ids are minted per VU (ws-1, ws-2, …) and are valid only
inside that VU's current iteration — an id never crosses into the next
iteration or another VU. Whatever a scenario leaves open is dropped at
iteration end (abruptly — use ws-close for a clean shutdown), and
ws-connect inside a config's before: setup is not useful: the setup
context and its sockets are gone before VUs start.
Dynamic messages
Text payloads may embed single-brace ${…} tokens (${seq}, ${uuid},
${now}/${now_ms}/${now_iso}, ${rand(a,b)}, ${randf(a,b[,dp])},
${choice(x|y|z)}), expanded anew per send — distinct from ${{ … }}, which
resolves once before the action runs:
- use: std/ws-send@v1
with:
id: "${{ feed.id }}"
send: '{"op":"order","id":"ord-${seq}","px":${randf(1.05,1.15,5)}}'
repeat: 100
interval_ms: 50
The full token table is in the
std/ws-send@v1 reference.
Asserting messages
Receive steps expose a messages list; std/check@v1 asserts over it with
the any quantifier (at least one message matches — streams carry
heartbeats and unrelated events):
check:
message_contains: "trade" # some message contains the substring
message_matches: { type: trade } # some message JSON-subset-matches
messages_count_gte: 5 # at least 5 messages arrived
For deterministic exchanges, address one message by index:
check: { on: got.messages.0, message_matches: { type: welcome } }.
Metrics
- Handshakes and one-shot sessions feed the shared latency histogram
(
http_req_duration) — comparable with HTTP/TCP/UDP percentiles; failed handshakes count inhttp_req_failed. ws_msg_rtt— application-level message RTT: time from a send to the first reply matching your until-rule, aggregated as a histogram (p50 / p95 / max in the summary).ws_msgs_sent/ws_msgs_received— message throughput counters.- Waiting on a server-push stream is deliberately not counted as latency — how long a server chooses to wait before pushing is not target latency and would poison the shared percentiles.
Limits
Inbound protocol limits come from the WebSocket library defaults: messages up
to 64 MiB, single frames up to 16 MiB — a larger inbound message errors the
connection (and therefore the step reading it). Timeout values, repeat
counts, and the messages list have no built-in caps.