What you'll build
A load test that opens real WebSocket connections against your endpoint, subscribes to a channel, streams unique messages from one template, waits for matching replies, and measures the message round trip — scaled across concurrent virtual users, with pass/fail assertions you can gate CI on.
We'll go in five steps: a one-step session, reading the output, a live
connection held across steps, dynamic message streams with RTT, and stream
assertions. Everything here is free and open source — the std/ws*
actions ship in the perfscale engine on every plan.
Prerequisites
- The
perfscaleCLI (install instructions):npm install -g @perfscale/exe, orperfscale self-updateto be current (WebSocket needs v0.6.0+) - A WebSocket endpoint to test: your staging server, or any local echo
(
npx wscat --listen 9222works) for following along
Step 1 — A one-step session
The simplest WebSocket test is std/ws@v1: connect, send, wait for the
reply, close — one step, timed as one latency sample.
Create test.yaml:
steps:
- name: echo smoke
uses: std/ws@v1
with:
url: ws://127.0.0.1:9222
messages:
- send: '{"type":"hello","id":"${uuid}"}'
until_contains: "hello"
Native tests always run with a config. Create config.yaml:
vus: 1
duration: 10s
Run it:
perfscale run -f test.yaml -c config.yaml
The until_contains rule is the step's stopping condition: read from the
stream until a message contains hello, then close. If nothing matches
within the timeout (10s by default), the step fails — that's your signal
under load, not an excuse to retry.
For wss:// endpoints, TLS comes from the URL scheme. Self-signed staging
certificate? Add skipTLSVerify: true (and never point it at hosts you
don't control). Auth tokens go in headers; subprotocols like graphql-ws
in subprotocols.
Step 2 — Reading the output
The summary block prints WebSocket metrics next to the shared latency histogram:
http_req_duration......: avg=3.10ms p(50)=2.80ms p(90)=5.20ms p(95)=6.10ms p(99)=9.00ms min=1.10ms max=12.00ms
ws_msgs_sent: 500 50.00/s
ws_msgs_received: 500 50.00/s
ws_msg_rtt: avg=1.85ms p(50)=1.70ms p(90)=2.60ms p(95)=3.10ms p(99)=4.80ms min=0.90ms max=6.20ms count=500
Three things to know, because they're deliberate:
http_req_durationcarries the session time (and, for live connections, the handshake) — operations with a start and end the target controls. Your WS percentiles are directly comparable with HTTP/TCP/UDP.ws_msg_rttis the application-level message round trip: from a send to the first reply matching your until-rule. It only exists when you've told the engine what counts as a reply.- Time spent waiting on a server-push stream is in neither. A server that pushes every 30 seconds isn't slow — counting that wait as latency would poison every percentile you care about.
Step 3 — A live connection across steps
One step per session is fine for smoke checks. Real scenarios subscribe
once, then keep talking. std/ws-connect@v1 opens a live connection
that stays up for the rest of the iteration; later steps address it by the
id it returned:
steps:
- name: open feed
uses: std/ws-connect@v1
with:
url: ws://127.0.0.1:9222
outputs: feed
- name: subscribe
uses: std/ws-send@v1
with:
id: "${{ feed.id }}"
send: '{"op":"subscribe","channel":"orders"}'
- name: wait for ack
uses: std/ws-recv@v1
with:
id: "${{ feed.id }}"
until_json: { type: subscribed }
outputs: ack
- name: keep-alive rtt
uses: std/ws-ping@v1
with: { id: "${{ feed.id }}" }
check:
duration_ms_lt: 250
- name: hang up
uses: std/ws-close@v1
with: { id: "${{ feed.id }}" }
Notes on the moving parts:
until_json: { type: subscribed }is a JSON-subset match — the reply must have"type": "subscribed"; extra fields are ignored. Much sturdier than substring matching against serialized JSON.std/ws-ping@v1measures the transport-level ping→pong round trip. It's in the step'sduration_ms(bounded here with a check), deliberately not aggregated into any histogram.- The connection never outlives its iteration. Whatever you leave open is
dropped at iteration end — abruptly, no Close handshake — so call
ws-closewhen you want a clean shutdown. - Because the connection is just an id, you can put HTTP steps in between: subscribe over WS, POST an order over REST, then assert the fill arrives on the socket. That's the test most realtime systems actually need.
Step 4 — Dynamic message streams
A load test that sends the same message forever tests your cache, not your
system. Text payloads expand single-brace ${…} tokens per send, and
repeat + interval_ms turn one template into a stream:
- name: order burst
uses: std/ws-send@v1
with:
id: "${{ feed.id }}"
send: >-
{"op":"order","id":"ord-${seq}","symbol":"${choice(EURUSD|GBPUSD|USDJPY)}",
"qty":${rand(1000,100000)},"px":${randf(1.05,1.15,5)},"ts":${now_ms}}
repeat: 100
interval_ms: 50
- name: first fill
uses: std/ws-recv@v1
with:
id: "${{ feed.id }}"
until_json: { type: fill }
timeout: 5000
That's 100 unique orders per iteration per VU — fresh ids, varied symbols,
random sizes — one every 50ms. The ${…} tokens (seq, uuid, now,
now_ms, now_iso, rand, randf, choice) are distinct from the
engine's ${{ … }} interpolation, which resolves once before the step runs.
Because a send preceded the until_json match on this connection, the
engine records the send→match time as a ws_msg_rtt sample. Scale vus up
in config.yaml and the summary's p(95) tells you how your matching
engine behaves under real concurrent order flow.
Step 5 — Asserting on streams
Streams are noisy: heartbeats, presence events, other users' traffic. So
message assertions in std/check@v1 use at-least-one-matches semantics
over the messages list a receive step outputs:
- name: collect a window
uses: std/ws-recv@v1
with:
id: "${{ feed.id }}"
count: 20
timeout: 10000
outputs: got
check:
messages_count_gte: 20
message_matches: { type: fill, symbol: EURUSD }
message_contains: "ord-"
For deterministic exchanges — say the first frame is always a welcome message — address one message by position:
- uses: std/check@v1
with:
on: got.messages.0
message_matches: { type: welcome }
Failing checks print FAIL on stderr and mark the run's check rate, but the
run completes — same as k6 without thresholds. Gate CI on the output, or on
the check rate your reporting pipeline sees.
Reusable connection profiles
Deploy the same test against staging and prod by moving the endpoint into a connection profile in your config:
# config.yaml
vus: 50
duration: 2m
variables:
feed_profile: >-
{"url":"wss://staging.example.com/feed",
"headers":{"Authorization":"Bearer ${TOKEN}"},
"subprotocols":["graphql-ws"]}
# test.yaml
- uses: std/ws-connect@v1
with:
connection: "${{ vars.feed_profile }}"
The profile supplies any connect parameter; inline fields override it.
Where to go next
- WebSocket concepts — the full parameter reference and the metrics model
examples/websocket.test.yaml— a runnable scenario with both styles- Run it from the platform: create a native test, paste your YAML, and the metrics page switches to WebSocket tiles — message throughput, RTT percentiles, handshake latency — automatically.