Resource library

QA How-To

k6 WebSocket Load Testing Step by Step (2026)

Follow k6 WebSocket load testing step by step with runnable scripts, message latency metrics, staged virtual users, thresholds, and CI-ready results in CI.

22 min read | 2,633 words

TL;DR

Install k6 v2.0.0, connect with the stable k6/websockets API, send uniquely identified messages, and record each matching reply in a custom Trend. Ramp virtual users gradually, threshold connection errors and p95 round-trip time, then run the same script in CI with environment-specific limits.

Key Takeaways

  • Use k6 v2.0.0 with the stable k6/websockets module rather than the deprecated experimental import.
  • Measure connection success, message delivery, and application round-trip latency separately.
  • Give every message a correlation id so concurrent replies can be matched to their send timestamps.
  • Ramp connection-oriented workloads with ramping-vus and keep each virtual user connected for a controlled session.
  • Threshold checks, error rates, and message counts turn a successful command into a meaningful performance gate.
  • Validate against a small echo service before pointing the script at a shared test environment.
  • Model heartbeats, message frequency, session length, and reconnect behavior from production observations.

k6 websocket load testing step by step means more than opening many sockets. A useful test proves that connections open, messages continue to flow, replies match requests, latency stays within a target, and the system closes sessions cleanly under a defined load. This tutorial builds that test with k6 v2.0.0 and the stable k6/websockets module.

You will start against a public echo endpoint so the script is immediately runnable. You will then parameterize the URL, add correlated round-trip metrics, ramp concurrent sessions, enforce thresholds, and prepare the test for CI. For the wider performance workflow around workload design and result interpretation, read the k6 performance engineering complete guide.

The examples use plain JavaScript supported by k6. They do not require Node.js at runtime, and they avoid the deprecated k6/experimental/websockets import.

What You Will Build

By the end, you will have a WebSocket test that:

  • Opens one persistent connection per virtual user to wss://echo.websocket.org or your own endpoint.
  • Sends JSON messages on a fixed cadence and correlates each echo by id.
  • Records connection failures, malformed replies, sent and received counts, and round-trip latency.
  • Ramps through configurable connection levels with deterministic session duration.
  • Fails locally or in CI when error rate or p95 message latency exceeds its service-level objective.

The final script represents a connection-oriented workload. That distinction matters. An HTTP iteration often completes in milliseconds, while a WebSocket virtual user can remain occupied for minutes. Capacity planning therefore starts with concurrent open sessions and per-session message rate, not requests per second alone.

Prerequisites

Use these exact tutorial versions:

  • k6 v2.0.0. Confirm with k6 version.
  • Git 2.45 or newer if you store the script in source control.
  • A terminal on macOS, Linux, or Windows PowerShell.
  • Access to wss://echo.websocket.org for the practice run, or a non-production WebSocket endpoint you are authorized to test.

Install k6 from the official package for your operating system. On macOS with Homebrew:

brew install k6
k6 version

On a Debian-based Linux agent, use the official Grafana package repository rather than an unverified binary. If k6 is already installed, make the version explicit in your build image so a major upgrade cannot silently alter results.

Create an empty working directory:

mkdir k6-websocket-test
cd k6-websocket-test

Verification: k6 version should report k6 v2.0.0. If it reports v1.x, upgrade before using this tutorial because the exact baseline and CLI behavior here are for v2.0.0.

Step 1: Run the Smallest k6 WebSocket Load Testing Step by Step Script

Create smoke.js:

import { WebSocket } from 'k6/websockets';
import { check } from 'k6';
import { setTimeout } from 'k6/timers';

export const options = {
  vus: 1,
  iterations: 1,
};

export default function () {
  const socket = new WebSocket('wss://echo.websocket.org');

  socket.addEventListener('open', () => {
    socket.send(JSON.stringify({ type: 'hello', text: 'k6 smoke test' }));
  });

  socket.addEventListener('message', (event) => {
    const message = JSON.parse(event.data);
    check(message, {
      'echo contains expected text': (value) => value.text === 'k6 smoke test',
    });
    socket.close(1000, 'smoke complete');
  });

  socket.addEventListener('error', (event) => {
    console.error(`WebSocket error: ${event.error}`);
  });

  setTimeout(() => socket.close(1000, 'smoke timeout'), 10000);
}

Run it:

k6 run smoke.js

WebSocket uses the global k6 event loop. The open listener sends only after the handshake succeeds. The message listener parses the echo, checks its content, and closes normally with status code 1000. The timeout prevents a broken server from keeping the virtual user alive forever.

Verification: the summary should show one passed check and WebSocket message metrics with at least one sent and one received message. A clean run returns exit code 0. If the endpoint adds fields, the check still focuses on the contract this test owns: text.

Step 2: Parameterize the Endpoint and Authentication

A script tied to one URL cannot move safely between local, staging, and pre-production environments. Read configuration from k6 environment variables and pass authentication in connection parameters. Replace smoke.js with configurable.js:

import { WebSocket } from 'k6/websockets';
import { check, fail } from 'k6';
import { setTimeout } from 'k6/timers';

const wsUrl = __ENV.WS_URL || 'wss://echo.websocket.org';
const token = __ENV.WS_TOKEN || '';

export const options = { vus: 1, iterations: 1 };

export default function () {
  if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
    fail('WS_URL must use ws:// or wss://');
  }

  const params = {
    tags: { endpoint: 'events' },
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  };
  const socket = new WebSocket(wsUrl, [], params);

  socket.addEventListener('open', () => {
    socket.send(JSON.stringify({ type: 'ping', sentAt: Date.now() }));
  });

  socket.addEventListener('message', (event) => {
    check(event, { 'reply is text': (e) => typeof e.data === 'string' });
    socket.close(1000, 'verified');
  });

  socket.addEventListener('error', (event) => console.error(String(event.error)));
  setTimeout(() => socket.close(1000, 'timeout'), 10000);
}

Run the default echo target:

k6 run configurable.js

Run an authenticated environment without committing the token:

K6_WS_URL=wss://staging.example.test/events \
K6_WS_TOKEN=replace-at-runtime \
k6 run configurable.js

k6 exposes script variables prefixed with K6_ by stripping that prefix only for built-in option variables, not arbitrary __ENV names. Therefore the script above expects WS_URL and WS_TOKEN, so use this portable form instead:

WS_URL=wss://staging.example.test/events \
WS_TOKEN=replace-at-runtime \
k6 run configurable.js

That correction is intentional: naming environment variables precisely prevents the common mistake of receiving an empty token.

Verification: run once with WS_URL=not-a-url. k6 should stop with the custom validation failure. Run again with the echo URL and confirm the endpoint:events tag appears when you export detailed metrics. Never print the token.

Step 3: Measure Correlated Message Round-Trip Latency

Built-in WebSocket metrics count traffic and session timing, but application latency needs message correlation. A reply may arrive after other messages, so a single global start time is unsafe. Give every request an id and store its send timestamp in a per-VU map.

Create latency.js:

import { WebSocket } from 'k6/websockets';
import { Counter, Rate, Trend } from 'k6/metrics';
import { setInterval, setTimeout, clearInterval } from 'k6/timers';
import exec from 'k6/execution';

const roundTrip = new Trend('ws_message_round_trip', true);
const sent = new Counter('ws_app_messages_sent');
const received = new Counter('ws_app_messages_received');
const badReplies = new Rate('ws_bad_reply_rate');

export const options = { vus: 1, iterations: 1 };

export default function () {
  const socket = new WebSocket(__ENV.WS_URL || 'wss://echo.websocket.org');
  const pending = new Map();
  let sequence = 0;
  let ticker;

  socket.addEventListener('open', () => {
    ticker = setInterval(() => {
      sequence += 1;
      const id = `${exec.vu.idInTest}-${sequence}`;
      const payload = { type: 'echo', id, sentAt: Date.now() };
      pending.set(id, payload.sentAt);
      socket.send(JSON.stringify(payload));
      sent.add(1);
    }, 1000);
  });

  socket.addEventListener('message', (event) => {
    received.add(1);
    try {
      const reply = JSON.parse(event.data);
      const started = pending.get(reply.id);
      const valid = typeof started === 'number';
      badReplies.add(!valid);
      if (valid) {
        roundTrip.add(Date.now() - started);
        pending.delete(reply.id);
      }
    } catch (error) {
      badReplies.add(true);
      console.error(`Invalid JSON reply: ${error.message}`);
    }
  });

  socket.addEventListener('close', () => {
    if (ticker) clearInterval(ticker);
    for (const id of pending.keys()) {
      badReplies.add(true, { reason: 'missing_reply' });
      pending.delete(id);
    }
  });

  socket.addEventListener('error', (event) => console.error(String(event.error)));
  setTimeout(() => socket.close(1000, 'session complete'), 10000);
}

The boolean argument on Trend marks values as time, so k6 displays milliseconds appropriately. exec.vu.idInTest keeps ids distinct across virtual users. The map also exposes missing replies when the connection closes. Do not calculate latency from the payload's server clock unless clocks are synchronized; this client-side round trip needs only one clock. For percentile interpretation, use the p95 and p99 latency guide.

Verification: run k6 run latency.js. Expect roughly nine or ten sent messages in a ten-second session, matching received messages, ws_bad_reply_rate at 0%, and populated ws_message_round_trip percentiles. Exact counts can differ by one because timer firing and close scheduling meet at the session boundary.

Step 4: Model Concurrent WebSocket Connections

Now convert the diagnostic into a load model. ramping-vus fits a session workload because each VU owns one long-lived connection. Arrival-rate executors model new iterations per unit of time and are less intuitive when an iteration intentionally lasts for an entire socket session.

Use this options block in latency.js:

export const options = {
  scenarios: {
    websocket_sessions: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 10 },
        { duration: '1m', target: 10 },
        { duration: '30s', target: 25 },
        { duration: '2m', target: 25 },
        { duration: '30s', target: 0 },
      ],
      gracefulRampDown: '15s',
    },
  },
};

Also replace the hardcoded close timer with a configurable session length:

const sessionMs = Number(__ENV.SESSION_MS || 20000);

// Inside export default, after listeners are registered:
setTimeout(() => socket.close(1000, 'session complete'), sessionMs);

A VU that closes after 20 seconds can start another iteration while its stage remains active, producing session churn as well as concurrency. If production clients stay connected for 30 minutes, use a longer SESSION_MS in a dedicated soak environment. Do not claim 25 VUs represents 25 continuously open connections at every instant. Handshake time, iteration restart, failures, and ramp boundaries create small gaps.

Workload control What it represents Best use
ramping-vus A changing pool of concurrent session workers Connection capacity and gradual ramps
constant-vus A stable pool of session workers Soak tests and steady baselines
constant-arrival-rate Iterations started per time unit Connection establishment rate or short sessions

For deeper executor choices, see k6 scenarios and executors.

Verification: run SESSION_MS=20000 k6 run latency.js. Watch vus rise to 10, then 25, and return to zero. Confirm WebSocket session and connecting metrics increase throughout the test rather than only once per VU.

Step 5: Add Thresholds That Fail the Test

Checks report correctness, while thresholds decide whether the run passes. Add explicit service objectives to the options object:

export const options = {
  scenarios: {
    websocket_sessions: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 10 },
        { duration: '1m', target: 10 },
        { duration: '30s', target: 25 },
        { duration: '2m', target: 25 },
        { duration: '30s', target: 0 },
      ],
      gracefulRampDown: '15s',
    },
  },
  thresholds: {
    ws_bad_reply_rate: ['rate<0.01'],
    ws_message_round_trip: ['p(95)<500', 'p(99)<1000'],
    ws_connecting: ['p(95)<1000'],
  },
};

These numbers are tutorial examples, not universal standards. Replace them with objectives derived from user expectations and a stable baseline. ws_connecting measures handshake time. ws_message_round_trip measures the application echo path. Keeping them separate tells you whether regression occurred before or after the upgrade completed.

A threshold evaluates only observed samples. Add a minimum-volume sanity check in your CI wrapper or inspect message counts, because a test that sends nothing cannot meaningfully prove low message latency. The k6 thresholds and checks tutorial explains this division between assertions and performance gates.

Verification: temporarily change the latency threshold to p(95)<1. The run should finish with a failed threshold and a nonzero exit code. Restore the real objective afterward. This deliberate negative test proves the pipeline will reject a regression instead of merely printing red text.

Step 6: Adapt the Echo Contract to Your Application

A real server may require a subscription message before it publishes updates. Replace the echo payload and handler with your documented protocol. For a topic subscription:

socket.addEventListener('open', () => {
  socket.send(JSON.stringify({
    action: 'subscribe',
    topic: `prices:${__ENV.SYMBOL || 'ACME'}`,
  }));
});

socket.addEventListener('message', (event) => {
  const message = JSON.parse(event.data);

  if (message.type === 'subscribed') {
    subscriptionAcks.add(1);
    return;
  }

  if (message.type === 'price') {
    priceUpdates.add(1);
    badReplies.add(typeof message.value !== 'number');
  }
});

Define subscriptionAcks and priceUpdates as Counter metrics in init context. Do not force request-reply correlation onto a server-push feed. For pushes, useful measurements include time to first update, updates per connected minute, sequence gaps, stale timestamps, and subscription acknowledgement rate. If the protocol carries correlation ids for commands, retain the map only for those command replies.

Authentication may arrive in an HTTP header, query string, cookie, or initial message. Follow the server contract, and never log a credential-bearing URL. If the application uses GraphQL subscriptions, the subprotocol and connection initialization messages must match that specification. The WebSocket testing guide provides broader functional coverage ideas before you scale.

Verification: begin with one VU and log only message type plus correlation id. Confirm the expected sequence, such as subscribed before price. Remove debug logging before load because console output adds contention and can overwhelm the runner.

Step 7: Test Disconnects, Timeouts, and Reconnection Separately

The main load test closes normally. Reconnection deserves a distinct scenario because combining many policies in one script makes results hard to interpret. Add close-code counters first:

import { Counter } from 'k6/metrics';

const normalCloses = new Counter('ws_normal_closes');
const abnormalCloses = new Counter('ws_abnormal_closes');

// Inside the VU function:
socket.addEventListener('close', (event) => {
  if (event.code === 1000) {
    normalCloses.add(1);
  } else {
    abnormalCloses.add(1, { code: String(event.code) });
  }
});

Then create a controlled experiment in which a test proxy or dedicated server closes selected sessions. Let the next VU iteration reconnect naturally, or implement bounded retry behavior that mirrors the client. Avoid immediate infinite reconnect loops. They create a retry storm exactly when the backend is least able to accept it. Production clients commonly use exponential backoff with jitter, a maximum delay, and an authentication refresh path, so your performance test should reproduce those rules rather than inventing more aggressive behavior.

Keep client heartbeat and protocol ping concepts separate. A JSON {type: 'heartbeat'} is application data. A WebSocket ping is a control frame. Use whichever the deployed client actually uses, measure missed acknowledgements, and ensure the server's idle timeout is longer than the expected heartbeat interval. Functional reconnection cases are covered in testing WebSocket reconnection logic.

Verification: force one abnormal close in a safe test environment. Confirm ws_abnormal_closes increments with its code and that the subsequent connection rate matches the intended backoff. A normal end-of-test shutdown should remain counted separately.

Step 8: Run the WebSocket Test in CI

Use a small, repeatable profile for pull requests and reserve larger tests for scheduled runs. A GitHub Actions job can pin the CLI and execute the same thresholds:

name: websocket-performance

on:
  workflow_dispatch:
  schedule:
    - cron: '30 2 * * 1-5'

jobs:
  k6-websocket:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: grafana/setup-k6-action@v1
        with:
          k6-version: '2.0.0'
      - name: Run WebSocket thresholds
        env:
          WS_URL: ${{ secrets.PERF_WS_URL }}
          WS_TOKEN: ${{ secrets.PERF_WS_TOKEN }}
          SESSION_MS: '20000'
        run: k6 run tests/websocket-load.js

Pin action revisions according to your organization's supply-chain policy, and confirm the setup action's supported inputs in its current documentation before adopting the workflow. Store URLs as secrets when they contain tenant identifiers or signed query parameters. Never schedule a load profile against production without explicit ownership, limits, monitoring, and a stop plan.

Archive the full k6 output or export metrics to your approved backend so a failed p95 can be compared with server CPU, event-loop lag, gateway connection counts, and message-broker depth. A threshold tells you that performance breached a line; correlated telemetry helps locate why.

Verification: trigger the workflow manually with the echo target first. Confirm a passing run is green. Use a temporary impossible threshold on a branch and confirm the job becomes red, then revert it. This tests the gate itself without stressing the application.

Reading the Results

Interpret the summary as a system narrative, not a single latency number:

Signal Question it answers Suspicious pattern
ws_connecting How long did upgrades take? p95 climbs while message latency stays flat
ws_sessions or session duration Did connections stay alive? Sessions terminate earlier than configured
ws_msgs_sent and ws_msgs_received Did frames continue flowing? Received count falls behind sent count
ws_message_round_trip How long did correlated replies take? Tail latency grows at a specific VU plateau
ws_bad_reply_rate Were replies valid and matched? Missing ids or parse failures appear under load
abnormal close counter Did the server or network end sessions? Non-1000 codes rise during ramp-up

One public echo run validates the mechanics, not your capacity. On your own environment, compare plateaus. If latency is stable at 100 connections and bends upward at 250, align that timestamp with server metrics and repeat to establish whether the knee is reproducible. Change one workload dimension at a time. Raising connections, message frequency, and payload size simultaneously prevents a defensible diagnosis.

Troubleshooting

Problem: unknown module: k6/websockets -> Your k6 binary predates the stable module. Run k6 version, install v2.0.0 for this tutorial, and remove any k6/experimental/websockets import. Do not run the script with Node.js because k6 modules exist only inside the k6 runtime.

Problem: the handshake returns 401 or 403 -> Confirm where the application expects credentials. Inspect a successful client handshake in an authorized environment, then reproduce its header, cookie, query parameter, and subprotocol requirements. Verify that WS_TOKEN is present without printing its value.

Problem: the test never finishes -> Ensure every path eventually calls socket.close(). Register a maximum session timeout before waiting for messages, clear recurring timers in the close listener, and check whether the server withholds the expected acknowledgement.

Problem: sent messages greatly exceed received messages -> Look for server throttling, dropped subscriptions, close events, and unmatched correlation ids. Allow a brief drain period if the application contract permits it, but do not hide losses by extending the test indefinitely. Count pending ids at close.

Problem: latency is implausibly negative or enormous -> Do not subtract clocks from different machines. Record Date.now() before send and after receipt inside the same VU. If you measure server processing time from server timestamps, synchronize clocks and label that metric separately.

Problem: the generator reaches high CPU before the server does -> Remove console logging, reduce metric tag cardinality, and monitor the load generator itself. Correlation ids must not become metric tags because each unique id creates another series. Distribute tests only after one generator's safe limit is measured.

Interview Questions and Answers

The JSON interview section below contains model answers you can rehearse. In a real interview, explain the workload in connection terms, distinguish handshake and message latency, and describe how you proved message delivery rather than saying only that k6 opened sockets.

A strong practical answer also mentions generator health, bounded retries, threshold failure, and correlation with server telemetry. Those details show that you can operate a test, not just write an API call.

Common Mistakes

  • Treating WebSocket sessions like independent HTTP requests and choosing an arrival model without checking iteration lifetime.
  • Importing deprecated k6/experimental/websockets code into a new v2.0.0 test.
  • Sending before the open event or closing before pending replies can arrive.
  • Recording one start timestamp for multiple in-flight messages, which corrupts latency values.
  • Using unique message ids as metric tags and exhausting the metrics backend with cardinality.
  • Calling every received push a reply when the protocol includes unsolicited broadcasts.
  • Testing only the handshake while claiming that message throughput was validated.
  • Printing every frame under load and benchmarking terminal I/O instead of the server.
  • Reusing a production token in source control or logs.
  • Selecting thresholds from guesswork without a user objective or baseline.
  • Ignoring the load generator's CPU, memory, network, and file-descriptor limits.
  • Scaling a public echo service beyond a tiny practice run.
  • Combining connection ramp, larger payloads, faster messages, and fault injection in one first experiment.
  • Allowing endless immediate reconnects after failure.

Where To Go Next

Move the final script to your performance repository and replace the echo contract with one real, documented user journey. Capture production-like session duration and message frequency from approved telemetry, run a five-user shakeout, and only then increase the plateau.

Continue with designing k6 load scripts, then learn how to find a performance bottleneck. If your WebSocket service runs across clusters, the k6 distributed testing with the Kubernetes Operator tutorial is the next scaling step. You can also practice explaining the test aloud in the QA interview practice area or tailor your performance evidence in the resume upload dashboard.

The finished k6 WebSocket load test should answer five concrete questions: how many sessions were open, how quickly handshakes completed, how much message traffic flowed, how long correlated replies took, and what failed. If the result cannot answer all five, improve the instrumentation before increasing load.

Interview Questions and Answers

How would you design a WebSocket load test in k6?

I would model concurrent sessions, session duration, message rate, payload mix, and reconnect policy from observed client behavior. Each VU would own a controlled connection, while custom metrics would track application messages and correlated round-trip latency. I would ramp gradually, monitor the generator and server, and gate the run with error and latency thresholds.

What is the difference between WebSocket handshake latency and message latency?

Handshake latency covers the HTTP upgrade and connection establishment, which k6 exposes through WebSocket connecting metrics. Message latency starts after the connection is open and measures an application exchange or delivery delay. Separating them shows whether a bottleneck is in admission, authentication, or ongoing message processing.

How do you correlate WebSocket requests and responses under load?

I assign every request a unique id, record its send time in a map local to the VU, and look up that id when a response arrives. A matching response records a Trend sample and removes the entry. Entries left at close become missing-response errors instead of disappearing silently.

Why might ramping-vus be preferable to constant-arrival-rate for persistent sockets?

Ramping-vus directly controls the pool of session workers, so it maps naturally to concurrent connected clients when each iteration owns a socket. Constant-arrival-rate controls iteration starts, which is better for connection-attempt rate or short sessions. I still verify actual open connections because one VU is not automatically one socket at every instant.

What thresholds would you use for a WebSocket performance test?

I would set objectives for handshake p95, application round-trip p95 or p99, invalid or missing reply rate, and abnormal closes. Values must come from product requirements and baselines rather than a generic template. I also require enough sent and received volume for the percentile to be meaningful.

How do you test WebSocket server-push performance when there is no request-response pair?

I measure subscription acknowledgement, time to first event, events received per connected minute, sequence gaps, stale event age, and malformed payload rate. I avoid labeling every pushed event as a response. If server timestamps are used for one-way delay, clock synchronization becomes an explicit prerequisite.

How do you prevent reconnect storms in a load test?

I reproduce the real client's bounded exponential backoff, jitter, maximum delay, and token refresh behavior. I cap retries and track reconnect attempts as their own metric. Immediate unbounded retries create an unrealistic secondary load spike and can hide the original failure.

How do you know whether k6 itself is the bottleneck?

I monitor runner CPU, memory, network throughput, and operating-system limits while watching whether latency changes with generator utilization. I remove frame-level logging and avoid high-cardinality tags. Before distributing the run, I establish a safe per-generator capacity with headroom and compare server-side connection counts to k6 metrics.

Frequently Asked Questions

Can k6 load test WebSockets?

Yes. k6 v2.0.0 includes the stable k6/websockets module for opening connections, sending text or binary data, and handling open, message, error, and close events. k6 also emits WebSocket-specific metrics, and you can add custom metrics for application behavior.

Should I use k6/websockets or k6/ws in 2026?

Use k6/websockets for new tests when its browser-style API meets your needs. It uses the global event loop and supports multiple concurrent connections per VU. k6/ws remains available, but Grafana recommends the newer stable API when possible.

How do I measure WebSocket message latency in k6?

Put a unique correlation id and client send timestamp in each command, store the timestamp in a per-VU map, and add the elapsed time to a custom Trend when the matching reply arrives. Do not mix clocks from the client and server for a round-trip metric.

How many virtual users equal how many WebSocket connections?

One VU can represent one persistent connection in the simple pattern used here, but reconnects, iteration restarts, handshake gaps, or multiple sockets per VU change the relationship. Use connection metrics and server-side active connection counts to verify actual concurrency.

Which k6 executor is best for WebSocket load testing?

Ramping-vus is a clear choice for gradually changing concurrent session workers, while constant-vus suits a steady soak. An arrival-rate executor is useful when connection attempts per second, rather than stable session concurrency, is the primary workload.

Why does a k6 WebSocket test hang?

The socket or a recurring timer is probably still active. Add a maximum session timer, close the socket on success and failure paths, and clear intervals in the close handler. Also confirm the server actually sends the event your script awaits.

Can I run a k6 WebSocket test in CI?

Yes. Pin the k6 version, inject endpoint and token values through CI secrets, execute k6 run, and let failed thresholds produce a nonzero exit code. Keep pull-request profiles small and schedule larger authorized tests separately.

Related Guides