k6 can load test an MQTT broker through the xk6-mqtt extension: you write the test in JavaScript with an MQTT.js-style Client, k6 runs it across hundreds or thousands of virtual users, and thresholds turn the result into a pass/fail check you can run in CI. It is a good fit when you want load tests as code, versioned next to your application.
This guide covers getting a k6 binary with MQTT support, the event-loop model that trips up most first scripts, a publisher/subscriber load script with an end-to-end latency metric, and thresholds that fail the run when the broker gets slow.
Get k6 with MQTT support
MQTT is not part of the core k6 binary. It comes from the xk6-mqtt extension maintained by Grafana, imported as k6/x/mqtt. There are two ways to use it:
Automatic extension resolution (k6 v1.2.0 and later). Import the module in your script and run it as usual; k6 detects the import and provisions the extension:
k6 version # needs v1.2.0 or newer
k6 run mqtt-smoke.jsCustom binary with xk6. Useful in air-gapped CI or when you want to pin the exact extension version:
go install go.k6.io/xk6/cmd/xk6@latest
xk6 build --with github.com/grafana/xk6-mqtt
./k6 run mqtt-smoke.jsA smoke test first
Start with one virtual user that connects, subscribes, publishes one message and disconnects when it comes back. If this fails, a load test will only fail faster.
import { Client } from "k6/x/mqtt";
const BROKER = __ENV.MQTT_URL || "mqtt://broker.connectmq.com:1883";
export default function () {
const client = new Client({
username: __ENV.MQTT_USER,
password: __ENV.MQTT_PASS,
});
const topic = "testmqtt/k6/smoke";
client.on("connect", () => {
client.subscribe(topic, { qos: 1 });
client.publish(topic, "hello from k6", { qos: 1 });
});
client.on("message", (t, payload) => {
console.log(`received on ${t}: ${new TextDecoder().decode(payload)}`);
client.end();
});
client.on("error", (err) => {
console.error(`${err.method}: ${err.message}`);
client.end();
});
client.connect(BROKER);
}MQTT_USER=username MQTT_PASS=password k6 run mqtt-smoke.jsKeep the online MQTT client subscribed to testmqtt/k6/# while you run it: you will see the message arrive, which confirms the topic and credentials independently of k6. The shared endpoint broker.connectmq.com is fine for this smoke test; run the load script below against your own private broker instance by setting MQTT_URL and the credentials.
The event-loop model
This is the part that differs from HTTP tests. With xk6-mqtt, an iteration does not end when the default function returns. The VU keeps running its event loop — handling connect, message and timer callbacks — until the connection closes, either because you call client.end() or because the broker drops it.
Consequences for your scripts:
- One iteration = one MQTT session. Decide how long a session lasts and end it explicitly with
setTimeout(() => client.end(), …). - Use
setIntervalinside theconnecthandler to publish at a fixed rate.sleep()would block the event loop. - Always handle
errorand callclient.end()there, or a failing VU can hang until the scenario's graceful stop. - Give every connection a unique
client_id(or leave it empty so the broker assigns one). Duplicates cause session takeovers that look like random disconnects.
A load script with end-to-end latency
The built-in metrics count connects, publishes and received messages and time connections, but they do not tell you how long a message took from publisher to subscriber. Add that yourself: put Date.now() in the payload and record the difference in a custom Trend. Publisher and subscriber run in the same VU, so they share one clock.
import { Client } from "k6/x/mqtt";
import { Trend, Counter } from "k6/metrics";
import exec from "k6/execution";
const BROKER = __ENV.MQTT_URL || "mqtt://broker.connectmq.com:1883";
const SESSION_MS = 30000; // one iteration = one 30 s session
const INTERVAL_MS = 100; // 10 messages per second per VU
const e2eLatency = new Trend("mqtt_e2e_latency", true);
const mqttErrors = new Counter("mqtt_errors");
export const options = {
scenarios: {
telemetry: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{ duration: "30s", target: 100 },
{ duration: "3m", target: 100 },
{ duration: "15s", target: 0 },
],
gracefulRampDown: "35s",
},
},
thresholds: {
mqtt_e2e_latency: ["p(95)<100", "p(99)<250"],
mqtt_connect_duration: ["p(95)<1000"],
mqtt_errors: ["count<1"],
},
};
export default function () {
const vu = exec.vu.idInTest;
const topic = `testmqtt/k6/${vu}/data`;
const client = new Client({
client_id: `k6-${vu}-${exec.vu.iterationInScenario}`,
username: __ENV.MQTT_USER,
password: __ENV.MQTT_PASS,
});
client.on("connect", () => {
client.subscribe(topic, { qos: 1 });
let seq = 0;
const timer = setInterval(() => {
const body = JSON.stringify({ seq: seq++, t: Date.now(), pad: "x".repeat(200) });
client.publish(topic, body, { qos: 1 });
}, INTERVAL_MS);
setTimeout(() => {
clearInterval(timer);
client.end();
}, SESSION_MS);
});
client.on("message", (t, payload) => {
const msg = JSON.parse(new TextDecoder().decode(payload));
e2eLatency.add(Date.now() - msg.t);
});
client.on("error", (err) => {
mqttErrors.add(1, { method: err.method });
client.end();
});
client.connect(BROKER, { keepalive: 30 });
}At 100 VUs × 10 messages per second the target is 1,000 published and 1,000 delivered messages per second. The payload is about 250 bytes; adjust pad to match your devices, and check the wire size with the packet size calculator.
gracefulRampDown is set a little above the session length so VUs can finish their current session during ramp-down instead of being cut off mid-stream, which would show up as missing messages.
Thresholds that fail the run
Thresholds make k6 exit with a non-zero code when a condition is violated, which is what you want in a pipeline:
| Threshold | Meaning |
|---|---|
mqtt_e2e_latency: p(95)<100 | 95 % of messages delivered in under 100 ms |
mqtt_e2e_latency: p(99)<250 | Tail latency stays under 250 ms |
mqtt_connect_duration: p(95)<1000 | Connections (including TLS) complete within 1 s |
mqtt_errors: count<1 | Any MQTT error fails the test |
The numbers above are examples. Set them from your own baseline: run the test idle, note the p95/p99, and allow some headroom. Also compare the built-in counters mqtt_messages_sent and mqtt_messages_received in the end-of-test summary — at QoS 1 with one subscriber per topic, they should be close; a growing gap means messages are queued or lost. For deeper analysis see interpreting benchmark results.
Choosing an executor
Because one iteration is one MQTT session, the executor decides how many connections are open and how often new ones arrive:
| Executor | Models | Note |
|---|---|---|
constant-vus | A fixed fleet of always-connected devices | Concurrent connections = VUs |
ramping-vus | A fleet coming online in stages | Used in the script above; ramp slowly to see where latency bends |
constant-arrival-rate | New sessions starting at a fixed rate | Set preAllocatedVUs ≥ rate × session length, or k6 runs out of VUs |
per-vu-iterations | Each VU runs exactly N sessions | Handy for reconnect tests: short sessions, many iterations |
For a reconnect-storm test, shorten SESSION_MS to a few seconds and use constant-arrival-rate: every iteration then performs a full CONNECT, SUBSCRIBE and DISCONNECT cycle, and mqtt_connect_duration shows how connection time degrades as the arrival rate climbs. See reconnect storms for what to look for.
TLS and WebSockets
Change the URL scheme to test other transports with the same script:
k6 run -e MQTT_URL=mqtts://broker.connectmq.com:8883 mqtt-load.js
k6 run -e MQTT_URL=wss://broker.connectmq.com:8084/mqtt mqtt-load.jsTLS uses k6's standard TLS configuration (for example tlsAuth for client certificates). Comparing the same scenario over TCP and TLS is the quickest way to see what the handshake costs you — see MQTT TLS performance testing.
Running k6 in CI
- Keep
mqtt-smoke.jsas a fast gate on every pull request, and runmqtt-load.jsnightly or before releases. - Pass credentials as secrets through
-e MQTT_USER=… -e MQTT_PASS=…or environment variables; never hard-code them. - Export results with
--summary-export=summary.jsonor an output such as--out json=results.jsonto keep a history. - Run the load generator in the same region as your devices' broker endpoint and watch its CPU; a saturated generator inflates latency.
See MQTT testing in GitHub Actions for a complete workflow.
Watch k6 traffic in the online client
k6 with xk6-mqtt turns an MQTT load test into a short, reviewable script with clear pass/fail criteria. Get the event-loop model right, measure end-to-end latency yourself, and let thresholds tell you when a broker change made things slower.
Frequently asked questions
Does k6 support MQTT natively?
MQTT comes from the xk6-mqtt extension, imported as k6/x/mqtt. Recent k6 versions (v1.2.0 and later) resolve official extensions automatically when a script imports them; otherwise build a custom binary with xk6 build --with github.com/grafana/xk6-mqtt.
Why do old k6 MQTT examples use a different API?
Several community projects were also named xk6-mqtt, with constructor-based APIs. The current Grafana extension uses an MQTT.js-style Client with connect, subscribe, publish and on(event) handlers. Do not mix examples from both.
How do I measure MQTT latency in k6?
Embed Date.now() in each payload, subscribe to the same topic in the same VU, and add the difference to a custom Trend metric when the message arrives. Then put a p(95) or p(99) threshold on that Trend.



