Concepts
WebSocket
Load-test WebSocket endpoints — sessions, streams, and message assertions
Overview
perfscale drives WebSocket endpoints under load: 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.
WebSocket is part of the open-source engine — the std/ws* actions are
available on every plan.
Two styles, freely mixable:
- 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. - 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 HTTP steps: subscribe over WS, trigger via REST, assert the push arrives.
One-shot session
steps:
- name: subscribe and await first trade
uses: 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 }
| Param | Default | Description |
|---|---|---|
url | — | ws:// or wss:// target (required) |
headers | — | Extra handshake headers (auth tokens etc.) |
subprotocols | — | Offered Sec-WebSocket-Protocol values — a list or a single string (e.g. graphql-ws) |
skipTLSVerify | false | Accept any certificate — self-signed staging only |
connection | — | A profile object supplying defaults for any field |
messages | [] | Entries to send: a string template, or { send, repeat, interval_ms, until_contains, until_json } |
timeout | 10000 | Milliseconds for the whole session |
An entry with an until_* rule waits for its matching reply before the next
entry — and yields one message RTT sample (see Metrics).
Output:
{ "connected": true, "sent": 1, "received": 2, "messages": ["…"], "body": "…",
"subprotocol": "graphql-ws", "duration_ms": 41.5,
"metrics": { "ws_msgs_sent": 1, "ws_msgs_received": 2, "ws_msg_rtt": [8.1] } }
The step fails on handshake/transport errors or on any entry whose until_*
rule did not match in time — and still reports everything exchanged up to
that point, plus an error field naming the failing entry (message[i]: …).
A handshake failure yields
{ "connected": false, "error": "…", "duration_ms": … } and counts in
http_req_failed.
timeout is an inline, per-step parameter: a timeout placed inside a
connection profile is ignored.
Live connection
# config.yaml — 25 concurrent VUs, each holding its own connection per iteration
vus: 25
duration: 5m
# test.yaml
steps:
- name: open feed
uses: std/ws-connect@v1
with: { url: "wss://stream.example.com/feed" }
outputs: feed
- name: subscribe
uses: std/ws-send@v1
with:
id: "${{ feed.id }}"
send: '{"op":"subscribe","id":"sub-${seq}"}'
- name: await confirmation
uses: std/ws-recv@v1
with:
id: "${{ feed.id }}"
until_json: { type: subscribed }
outputs: got
- name: hang up
uses: std/ws-close@v1
with: { id: "${{ feed.id }}" }
std/ws-connect@v1— opens the connection, returns{ id, subprotocol, … }.std/ws-send@v1— sendssend(text,${…}tokens expand per send) orsend_base64(binary);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.
A connection never outlives its iteration: whatever a scenario leaves open is
dropped at iteration end (abruptly — use ws-close for a clean shutdown).
Connection ids
- 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. std/ws-connect@v1inside a config'sbefore:setup is not useful: the setup context (and its sockets) is gone before VUs start.- Referencing a closed or unknown id fails the step with an "unknown connection id" error.
std/ws-connect@v1
Takes the same target parameters as the one-shot session (url, headers,
subprotocols, skipTLSVerify, connection); timeout here bounds just
the handshake (default 10000 ms). Output:
{ "id": "ws-1", "connected": true, "subprotocol": "graphql-ws", "duration_ms": 3.1 }
subprotocol is the server-negotiated protocol, or null when the server
picked none. The handshake feeds http_req_duration; a failed handshake
counts in http_req_failed and the output is
{ "connected": false, "error": "…", "duration_ms": … }.
std/ws-send@v1
| Param | Default | Description |
|---|---|---|
id | — | Connection id from std/ws-connect@v1 (required) |
send | one of send/send_base64 | Text payload; ${…} tokens expand per send |
send_base64 | one of send/send_base64 | Binary payload (mutually exclusive with send) |
repeat | 1 | Emit N messages from the one template |
interval_ms | 0 | Gap between repeated sends, ms |
timeout | 10000 | For the whole send loop, ms |
Output: { "sent": N, "bytes": B, "duration_ms": …, "metrics": { "ws_msgs_sent": N } }.
A transport error fails the step and drops the connection; a parameter error
(both send and send_base64) leaves it usable.
std/ws-recv@v1
| Param | Default | Description |
|---|---|---|
id | — | Connection id (required) |
until_contains | — | Stop when a message contains this substring (mutually exclusive with until_json) |
until_json | — | Stop when a message JSON-subset-matches this object — pattern fields must equal, extra fields ignored |
count | 1 | Without an until_* rule: stop after N data messages |
timeout | 10000 | Deadline for the stopping rule, ms |
Output:
{ "messages": ["…"], "body": "…", "count": 2, "matched": true, "duration_ms": 8.4,
"metrics": { "ws_msgs_received": 2, "ws_msg_rtt": [7.9] } }
Text frames arrive as strings, binary frames as base64 strings; body is the
newline-joined text form, so check: { body_contains: … } works. matched
reports whether the stopping rule was reached, and every message read along
the way stays in messages, whatever the outcome. If the peer closes or the
transport dies first, the step fails, the output gains an error field, and
the connection is dropped; a plain timeout fails the step too but leaves the
connection usable. Ping/pong frames arriving during the read are ignored as
transport noise.
std/ws-ping@v1
Output: { "pong": true, "duration_ms": 0.4 }. Takes id and timeout
(default 10000 ms). The RTT is not aggregated into any histogram — bound it
with check: { duration_ms_lt: … } when needed. Data messages arriving while
waiting for the pong are buffered for the next std/ws-recv@v1. No pong
within timeout (or a closed connection) fails the step and drops the
connection.
std/ws-close@v1
Sends a Close frame and waits for the peer's acknowledgement. Takes id,
code (default 1000, normal closure), reason (default empty) — both sent
in the Close frame — and timeout. Output: { "closed": true, "duration_ms": … },
reported even when the peer does not acknowledge within timeout; the socket
is gone either way and the id is released.
Dynamic messages
Text payloads may embed single-brace ${…} tokens, expanded per send —
distinct from ${{ … }}, which resolves once before the action runs:
| Token | Expands to |
|---|---|
${seq} | Monotonic counter, unique per message (keeps counting across sends on the same connection) |
${uuid} | Random 32-hex id |
${now} / ${now_ms} / ${now_iso} | UTC timestamp (FIX shape / unix ms / RFC 3339) |
${rand(a,b)} / ${randf(a,b[,dp])} | Random integer / float in [a, b] |
${choice(x|y|z)} | Random pick |
Unknown tokens are left verbatim. send_base64 payloads are decoded once and
sent as-is — no token expansion.
- uses: 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
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
- Handshake and one-shot sessions feed the shared latency histogram
(
http_req_duration) — comparable with HTTP/TCP/UDP percentiles. ws_msg_rtt— application-level message RTT: time from a send to the first reply matching your until-rule. Shown on the test dashboard with p50 / p95 / max.ws_msgs_sent/ws_msgs_received— message throughput rates.- Waiting on a server-push stream is deliberately not counted as latency — it would poison the shared percentiles.
The metrics page detects a WebSocket run automatically and switches to the WS tile set (throughput, message RTT, handshake 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.