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 }
ParamDefaultDescription
urlws:// or wss:// target (required)
headersExtra handshake headers (auth tokens etc.)
subprotocolsOffered Sec-WebSocket-Protocol values — a list or a single string (e.g. graphql-ws)
skipTLSVerifyfalseAccept any certificate — self-signed staging only
connectionA profile object supplying defaults for any field
messages[]Entries to send: a string template, or { send, repeat, interval_ms, until_contains, until_json }
timeout10000Milliseconds 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 — sends send (text, ${…} tokens expand per send) or send_base64 (binary); repeat + interval_ms stream N messages from one template.
  • std/ws-recv@v1 — reads until a stopping rule: until_contains (substring), until_json (JSON-subset match), or plain count. Not reaching it within timeout fails the step.
  • std/ws-ping@v1 — transport ping→pong; RTT in the step's duration_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@v1 inside a config's before: 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

ParamDefaultDescription
idConnection id from std/ws-connect@v1 (required)
sendone of send/send_base64Text payload; ${…} tokens expand per send
send_base64one of send/send_base64Binary payload (mutually exclusive with send)
repeat1Emit N messages from the one template
interval_ms0Gap between repeated sends, ms
timeout10000For 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

ParamDefaultDescription
idConnection id (required)
until_containsStop when a message contains this substring (mutually exclusive with until_json)
until_jsonStop when a message JSON-subset-matches this object — pattern fields must equal, extra fields ignored
count1Without an until_* rule: stop after N data messages
timeout10000Deadline 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:

TokenExpands 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.