Protocols9 min readJuly 16, 2026

Load Testing WebSockets, Step by Step

From a one-step echo check to a hundred-order stream with RTT percentiles: sessions, live connections, dynamic message templates, and stream assertions with the perfscale native engine.

By Perfscale Team

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 perfscale CLI (install instructions): npm install -g @perfscale/exe, or perfscale self-update to be current (WebSocket needs v0.6.0+)
  • A WebSocket endpoint to test: your staging server, or any local echo (npx wscat --listen 9222 works) 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_duration carries 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_rtt is 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@v1 measures the transport-level ping→pong round trip. It's in the step's duration_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-close when 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.

Comments

Reply on Bluesky