Skip to main content

WebSocket Load Testing With k6 and JMeter, Tested

WebSocket load testing with k6 and JMeter, tested: 2,000 open sockets, refused handshakes that still exit 0, and a timeout that shifts every later JMeter reply.

WebSocket Load Testing With k6 and JMeter, Tested

WebSocket load testing opens many long-lived WebSocket connections at once, sends and receives messages over them, and measures how the server copes: how long the handshake takes, how fast replies and server pushes arrive, how many connections it holds, and how it fails. k6 supports WebSockets out of the box. JMeter needs the WebSocket Samplers plugin.

We ran both against a local WebSocket server with echo, push, slow-reply, drop and connection-cap modes. The surprises were in the error handling. In k6, refused handshakes left the exit code at 0 and still counted as sessions. In JMeter, one read timeout made every later reply on that connection belong to the request before it, and every one of those samples passed.

Key Takeaways

  • A WebSocket test measures different things from an HTTP test: handshake time, message round trip, push delay, open connections and close codes.
  • In k6, use k6/websockets. A refused handshake surfaces only as an onerror event, so count errors yourself and put a threshold on that count, or the run exits 0.
  • A k6 WebSocket iteration ends only when its sockets close. Forget close() and a 3-second test runs for 33 seconds.
  • JMeter’s request-response sampler takes the next frame as the reply. After one read timeout, every later sample on that connection is timed against the wrong message and still passes.
  • One machine held 2,000 open WebSockets in both tools: k6 used 620 MB with one socket per VU and 240 MB with 100 sockets per VU.

What is WebSocket load testing?

WebSocket load testing simulates many clients holding WebSocket connections to a server at the same time and exchanging messages over them. Unlike an HTTP test, where each request opens, waits and finishes, a WebSocket client connects once with an HTTP upgrade and then keeps the connection open, sending messages and receiving replies or unprompted server pushes for as long as the session lasts.

That changes what load means. A chat server, a price ticker or a multiplayer game is limited less by requests per second than by how many sockets it can keep open, how quickly it fans a message out to every subscriber, and what it does when it runs out of room. The protocol itself is defined in RFC 6455; the summary of load testing different network protocols puts WebSocket next to HTTP/2, gRPC and MQTT.

What should a WebSocket load test measure?

Measure five things: how long the opening handshake takes, the round trip of a message that expects a reply, the delay of messages the server pushes, how many connections are open at once, and how connections fail. Each tool reports some of these for you and leaves the rest to your script, so plan the measurement before you write the test.

What to measurek6 (k6/websockets)JMeter (WebSocket Samplers plugin)
Handshake timews_connectingElapsed time of the Open Connection sample
Request and reply round tripYour own Trend metric, timed in the scriptElapsed time of the Request-Response sample
Server push delayYour own Trend: receive time minus a timestamp in the messageA JSR223 PostProcessor on a Single Read sample
Messages sent and receivedws_msgs_sent, ws_msgs_receivedSample counts per label
Session lengthws_session_durationTime between Open and Close samples
Failed handshakesNot reported as such; count onerror eventsOpen Connection sample fails with the HTTP status
Server-side closeonclose event with the close codeNext read fails with the close frame’s status code

In k6, two of these have to come from your script: the round trip of your own messages and the delay of server pushes. Push delay needs a script in JMeter too. A server push carries no request to time, so the only way to know how late it arrived is to put the send time in the message and compare it with the receive time on the client.

How do you load test WebSockets with k6?

Import WebSocket from k6/websockets, open a connection in the default function, and handle the onopen, onmessage, onerror and onclose events. The iteration stays alive while any of its sockets is open and ends when the last one closes, so each script has to call close() at some point. k6 times the handshake for you; round trips need a custom Trend metric.

This is the echo test we ran on k6 v2.3.0. Each VU opens a connection, sends 20 messages 100 ms apart with the send time inside, records the round trip of each echo, and closes after the 20th reply:

import { WebSocket } from 'k6/websockets';
import { Trend } from 'k6/metrics';
import { check } from 'k6';

const rtt = new Trend('ws_echo_rtt', true);
const BASE = __ENV.WS_URL || 'ws://127.0.0.1:8090';

export const options = { vus: 10, duration: '10s' };

export default function () {
  const ws = new WebSocket(`${BASE}/echo`);
  let sent = 0;
  ws.onopen = () => {
    const id = setInterval(() => {
      ws.send(JSON.stringify({ n: sent++, t: Date.now() }));
      if (sent === 20) { clearInterval(id); }
    }, 100);
  };
  let got = 0;
  ws.onmessage = (e) => {
    const m = JSON.parse(e.data);
    rtt.add(Date.now() - m.t);
    got++;
    if (got === 20) ws.close();
  };
  ws.onerror = (e) => console.error('ws error: ' + e.error);
  ws.onclose = () => check(got, { 'got all 20 echoes': (g) => g === 20 });
}

Ten VUs for 10 seconds completed 50 sessions. The handshake took 2.71 ms on average (p95 5.69 ms) and the echo round trip 1.24 ms (p95 4 ms). The summary listed ws_sessions, ws_connecting, ws_msgs_sent, ws_msgs_received and ws_session_duration, and no http_req_* metrics at all: a pure WebSocket script produces no HTTP request samples. Date.now() has millisecond resolution, so a local round trip under 1 ms records as 0.

One number is easy to misread: ws_msgs_sent was 1,050 for 1,000 messages, because k6 counts the close frame as a sent message, one per session. Note also that k6 has two WebSocket modules. k6/ws is the older one, with a blocking ws.connect() and a callback per connection; k6/websockets follows the browser WebSocket API and runs on k6’s global event loop, which lets one VU hold several connections. k6/experimental/websockets still loads in v2.3.0 but prints a deprecation warning.

How does k6 report WebSocket errors?

k6 reports a failed WebSocket handshake only as an onerror event with the message “websocket: bad handshake”. The HTTP status the server answered is not in it, the failed attempt still counts in ws_sessions and ws_connecting, and the run exits with code 0. To make failures fail the test, count them in a custom metric and set a threshold on it.

We capped the server at 100 connections and pointed 150 VUs at it. The server answered 50 upgrade requests with HTTP 503. k6 logged 50 “bad handshake” errors, reported 150 sessions and 150 ws_connecting samples (failures included), 100 session durations, and exited 0. A check on received pushes failed 50 of 150 times, and that changed nothing either, because failed checks never fail a run without a threshold, as our guide to k6 checks shows.

The legacy k6/ws module does return the handshake status. ws.connect() gave 101 for the 100 accepted connections and 503 for the other 50, so check(res, { 'status is 101': (r) => r && r.status === 101 }) names the failure. With k6/websockets, count errors instead:

import { WebSocket } from 'k6/websockets';
import { Counter } from 'k6/metrics';

const wsErrors = new Counter('ws_errors');

export const options = {
  vus: 150,
  iterations: 150,
  thresholds: { ws_errors: ['count==0'] },
};

export default function () {
  const ws = new WebSocket('ws://127.0.0.1:8091/ticker?hz=2');
  ws.onopen = () => setTimeout(() => ws.close(), 5000);
  ws.onerror = (e) => {
    wsErrors.add(1, { error: String(e.error) });
  };
}

Against the same cap this run reported ws_errors count=50, failed the threshold and exited with code 99, the code CI pipelines treat as a failed test. Server-side closes reach the same handler. When our server closed connections with code 1011 after five messages, k6 fired onerror (“websocket: close 1011 (internal server error): server dropping”) and then onclose with code=1011; that run had no error threshold and exited 0. Because onerror fires for these closes too, the ws_errors counter and its threshold catch them.

Why does my k6 WebSocket test never finish?

A k6 iteration that opens a WebSocket lasts until that socket closes. If the script never calls close() and the server never closes the connection, the iteration cannot end on its own: k6 waits until the test’s duration is over, then for the graceful stop period, and then interrupts the VU. Close every socket from a timer or a message handler.

A test with 2 VUs and duration: '3s' whose script opened a socket, sent one message and never closed it ran for 33.0 seconds: the 3 seconds plus k6’s default 30-second gracefulStop. It finished with 0 complete and 2 interrupted iterations and no ws_session_duration samples, and it exited 0. Every executor ends a hung iteration the same way, at the end of its duration (for iteration-based executors, maxDuration, 10 minutes by default) plus gracefulStop; our guide to k6 scenarios and executors covers gracefulStop and gracefulRampDown. ws.onopen = () => setTimeout(() => ws.close(), 20000) is the simplest way to give each session a fixed length.

How many WebSocket connections can one machine hold?

Both tools held 2,000, the most we tried, on one laptop. On an Apple M1 Max, k6 v2.3.0 held 2,000 open WebSocket connections with 620 MB of memory when each VU held one, and 240 MB when 20 VUs held 100 each. JMeter 5.6.3 held 2,000 with one thread each at 900 MB of process memory, running with its default 1 GB heap. In k6, push delay stayed at a few milliseconds; the JMeter run did not measure it.

Run (2,000 connections, server pushes once a second)Peak openMax memoryHandshake avg / p95Push delay p95
k6, 2,000 VUs ramped over 20 s, 1 socket each2,000620 MB1.82 / 5.17 ms2 ms
k6, 2,000 VUs started at once, 1 socket each2,000580 MB78 / 248 ms3 ms
k6, 20 VUs, 100 sockets each, opened at once2,000240 MB98 / 341 ms2 ms
JMeter, 2,000 threads ramped over 20 s2,000900 MB2.9 / 8 msnot measured

The handshake column carries a lesson. Opening all 2,000 connections within about a second pushed the average handshake from 1.82 ms to 78-98 ms, whether 2,000 VUs opened one socket each or 20 VUs opened 100 each. The burst made the handshakes slow, whichever way the VUs held their sockets, so ramp connections up the way real clients would arrive, or you will measure your own stampede. Several sockets per VU cut k6’s memory by more than half at the same connection count. That matters when the generator, not the server, would otherwise become the limit, as covered in our guide to virtual users in load testing.

In JMeter, each thread holds at most one WebSocket connection, so 2,000 connections take 2,000 threads. The plugin creates no extra threads of its own and did not need a bigger heap at this size. In that run the 80,000 Single Read samples reported a median of 1,001 ms. That is how long each thread waited for the next once-a-second push, not how slow the server was.

How do you load test WebSockets with JMeter?

Install the WebSocket Samplers plugin by Peter Doornbosch, from the JMeter Plugins Manager or as a jar in lib/ext. Then build each thread’s session from its samplers: Open Connection, then Request-Response, Single Write or Single Read samplers that reuse that connection, then Close. Each thread holds one connection, and every sampler reports as an ordinary JMeter sample.

The plugin adds six samplers: Open Connection, Request-Response, Single Write, Single Read, Ping-Pong and Close. It also adds three config elements that drop frames you do not want a sampler to see (ping/pong, text and binary frame filters). The echo plan we ran on plugin 1.3.2 looked like this:

Thread Group (10 threads, 5 loops)
  WebSocket Open Connection     ws://127.0.0.1:8090/echo, connect timeout 2000 ms, read timeout 6000 ms
  Loop Controller (20)
    WebSocket request-response Sampler   use existing connection, text "m${__counter(FALSE,)}"
  WebSocket Close               close status 1000

It produced 1,100 samples: 50 Open samples answered 101 Switching Protocols (median 1 ms), 1,000 request-response samples answered with a median of 0 ms and a maximum of 17 ms, and 50 Close samples recorded status 1000. The same plan gave the same results on plugin 1.2.2, the version on LoadFocus’s JMeter engines, so a plan saved with the current plugin ran unchanged on the older one. A sampler that uses the existing connection, with no Open before it, fails with “Sampler configured for using existing connection, but there is no connection”.

Why do JMeter WebSocket samples report the wrong reply?

JMeter’s Request-Response sampler sends its message and takes the next frame that arrives as the reply. It never checks that the frame answers this message. If a reply arrives after the read timeout, the sample times out and the late reply becomes the response to the next request. From then on, every sample on that connection is timed against the wrong message.

We set a 1,000 ms read timeout against an endpoint that replies after 1,500 ms, and sent req-1, req-2 and req-3 with the response data saved. req-1 failed with “WebSocket I/O error: Read timed out” after 1,003 ms. The sample for req-2 passed in 500 ms, but its response was req-1. The sample for req-3 passed in 1,001 ms with req-2 as its response. Two green samples, both wrong, and nothing in the summary to show it. Set the read timeout above the slowest reply you accept, and put a unique id in each message with a Response Assertion that checks the reply carries it.

The sampler is also strict about frame types. When our server answered text messages with binary frames, every Request-Response sample failed with “Sampler error: unexpected frame type (binary)”, while k6 delivered the same frames to onmessage without complaint. When the server closed the connection with status 1011, the next sample failed with “unexpected frame type (close)” and the status code.

How do failed WebSocket connections show up in JMeter results?

A refused handshake fails the Open Connection sample with the server’s status, for example “Got unexpected status 503”, so the cause is plain. But every sampler after it in that thread fails too, because there is no connection to use. The error count in the summary then measures the plan’s length, not how many connections the server refused.

With the server capped at 100 connections, 150 threads each running Open, five Single Reads and Close produced 50 failed Opens, 250 failed reads (“Sampler configured for using existing connection, but there is no connection”) and 50 failed Closes (“No connection; nothing to close”): 350 errors from 50 refusals. Setting the Thread Group’s “Action to be taken after a Sampler error” to “Start Next Thread Loop” removed the follow-on failures and left 50 errors in 750 samples. The 33% error rate before the change matched the 50 of 150 refusals only because every thread ran the same seven samples; after it, the rate fell to 6.7% while the refusals stayed at 50. Count failed Open Connection samples for the share of refused connections.

How do you measure server push delay?

Put the server’s send time in each pushed message and subtract it from the time the client received it. k6 can do this in onmessage with a Trend metric. In JMeter, add a JSR223 PostProcessor to the Single Read sampler and take the receive time from the sample itself, not from the clock when the script runs.

This PostProcessor parses the pushed JSON, computes the delay from the sample’s end time, writes it into the response message and fails the sample above a limit passed in the Parameters field (200 in our run):

def msg = new groovy.json.JsonSlurper().parseText(prev.getResponseDataAsString())
long lag = prev.getEndTime() - (msg.t as long)
prev.setResponseMessage("push lag " + lag + " ms")
if (lag > (Parameters as long)) {
    prev.setSuccessful(false)
}

Our first version used System.currentTimeMillis() instead of prev.getEndTime(). In two runs it reported 508 ms and 678 to 679 ms of delay on the first read of every thread, failing 5 of 25 samples each time, while the sample timestamps showed the messages had arrived within 2 ms. The extra time was Groovy compiling the script on its first run, between the end of the sample and the line that read the clock. With prev.getEndTime(), all 25 reads passed at 0 to 2 ms. The guide to JSR223 and Groovy scripting in JMeter covers that first-run cost and the other script traps.

k6 or JMeter for WebSocket load tests?

Pick k6 for WebSocket-heavy tests written as code: support is built in, one VU can hold many connections, and connection handling is event-driven. Pick JMeter when the WebSocket steps belong to a larger JMeter plan, or when the team already builds tests in JMeter. Then the plugin’s samplers report like any other sample, timed for you.

k6 v2.3.0JMeter 5.6.3 + WebSocket Samplers
InstallBuilt in (k6/websockets)Plugin from the Plugins Manager
Connections per VU or threadManyOne
Memory for 2,000 connections (our runs)240-620 MB900 MB (1 GB default heap)
Handshake status on failureLegacy k6/ws onlyYes, in the sample
Request and reply matchingYour code (an id in the message)Next frame, unchecked
How failures are reportedonerror events; the run fails only with a thresholdFailed samples count in the error rate
Server pushesonmessage handlerSingle Read sampler, one frame per sample

Running WebSocket tests on LoadFocus

LoadFocus runs JMeter test plans and k6 scripts from 25 cloud regions, and its k6 page lists WebSocket connections among the supported k6 features. For WebSocket timings, mind one difference between the engines: LoadFocus builds its k6 result charts from HTTP request samples, and a pure WebSocket k6 script produces none.

On the JMeter side, the LoadFocus engines include the WebSocket Samplers plugin (version 1.2.2), so the Open, Request-Response, Read and Close samples of an uploaded plan appear in the results like any other sample. That makes JMeter load testing on LoadFocus the way to see WebSocket handshake and reply times in the cloud results today. A k6 WebSocket script still runs on k6 load testing with LoadFocus and puts its load on your server, but its ws_* metrics do not reach the result charts. For scenario ideas, the LoadFocus templates cover 5,000 open WebSocket connections and WebSocket ramp-up and ramp-down.

Frequently Asked Questions

How do you load test a WebSocket server?

Open many WebSocket connections at once from a load testing tool such as k6 or JMeter with the WebSocket Samplers plugin. Ramp them up gradually, send and receive messages on each, and keep the connections open as long as real clients would. Measure handshake time, reply round trip, push delay, open connections and close codes. In k6, also count failed handshakes yourself, because a refused handshake does not fail the run.

Does JMeter support WebSocket testing?

Not on its own. JMeter needs a plugin; the common one is WebSocket Samplers by Peter Doornbosch, installed from the JMeter Plugins Manager. It adds Open Connection, Request-Response, Single Write, Single Read, Ping-Pong and Close samplers, plus frame filters. Each JMeter thread holds one WebSocket connection.

How do you test WebSockets in k6?

Import WebSocket from the k6/websockets module, open a connection in the default function, and handle the open, message, error and close events. Close each socket when the session should end, or the iteration runs until the test’s duration and graceful stop period are over. Count errors in a custom metric with a threshold so failed connections fail the run.

Why does my k6 WebSocket test pass when connections fail?

With the k6/websockets module, a refused handshake only triggers an error event, and k6 does not mark the run as failed unless a threshold says so. In our test, 50 of 150 handshakes were refused with HTTP 503 and the run exited with code 0. A counter incremented in the error handler, with a threshold that allows a count of zero, made the same run exit with code 99.

How many WebSocket connections can one load generator handle?

It depends on memory and on how the tool maps connections to users. In our runs on one laptop, k6 held 2,000 connections with 620 MB at one connection per VU and with 240 MB at 100 per VU. JMeter held 2,000 with one thread per connection at 900 MB of process memory, with its default 1 GB heap.

Sources

  • The WebSocket Protocol, RFC 6455: rfc-editor.org/rfc/rfc6455, read 9 November 2026.
  • k6 k6/websockets and k6/ws modules, the global event loop and the deprecation of k6/experimental/websockets: Grafana k6 documentation, grafana.com/docs/k6/latest/javascript-api/k6-websockets/ and grafana.com/docs/k6/latest/using-k6/protocols/websockets/, read 9 November 2026.
  • WebSocket Samplers plugin features, one connection per thread, frame filters and read timeouts: the plugin’s README, bitbucket.org/pjtr/jmeter-websocket-samplers, read 9 November 2026. Sampler property names from the 1.3.2 jar on Maven Central.
  • LoadFocus engines: loadfocus.com/k6-load-testing and loadfocus.com/jmeter-load-testing, read 9 November 2026; the plugin version on the JMeter engines and the metrics kept from k6 runs are from the LoadFocus engine source.
  • Test runs: k6 v2.3.0 (official macOS arm64 release, SHA-256 verified) and Apache JMeter 5.6.3 (official release, SHA-512 verified) with WebSocket Samplers 1.3.2 and 1.2.2 (Maven Central, SHA-1 verified), on Java 11.0.19, against a local Node.js 23.11.1 server using the ws 8.18.3 library, on an Apple M1 Max, 9 November 2026. Client and server shared one machine, so the timings show patterns, not benchmarks.

Bogdan

Founder at LoadFocus

Bogdan builds and runs the tools this blog is about. He writes from what the products actually do in production, including the parts that break.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.