An MQTT benchmark produces numbers quickly. Producing numbers you can trust is harder. Most misleading results come from the same handful of mistakes: quoting averages, measuring the load generator instead of the broker, ignoring coordinated omission and comparing runs that were not set up the same way.
This guide walks through each pitfall, shows how to detect it in your own data and ends with a reporting template that makes results reproducible. It assumes you have already run a test — if not, start with MQTT broker load testing.
Averages hide the slow messages
Latency is never normally distributed. A broker under load delivers most messages in a few milliseconds and a small fraction in hundreds of milliseconds — during a garbage-collection pause, a disk flush or a retransmission. The mean mixes both groups into a number that describes neither.
| Run | Mean | p50 | p99 | Max |
|---|---|---|---|---|
| A | 6 ms | 4 ms | 12 ms | 30 ms |
| B | 6 ms | 3 ms | 140 ms | 900 ms |
(Illustrative values.) Both runs have the same average, but run B means one message in a hundred arrives more than a tenth of a second late. With a device publishing once per second, that is a visible delay roughly every two minutes.
Rules that keep you honest:
- Report percentiles: p50 (typical), p99 (the tail), max (the worst case you observed). Add p99.9 only when you have enough samples.
- Never average percentiles across runs or clients. The mean of ten p99 values is not the p99 of the combined data — merge the raw samples or histograms instead.
- Check sample size. p99 of 100 messages is one message. Collect thousands per run.
Coordinated omission
Coordinated omission is the most common reason a benchmark looks better than reality. It happens when the load generator waits for a response before sending the next request. If the broker stalls for 500 ms, a synchronous publisher records one slow message — and then simply doesn't send the 49 others that a real fleet would have sent during that half second. Those messages, which would all have seen high latency, never appear in the data.
Real devices do not coordinate with the broker. A sensor publishes on its own schedule whether the last PUBACK arrived or not. To reproduce that, measure latency from the intended send time, not from the moment the message was actually handed to the socket:
import json, statistics, time, uuid
import paho.mqtt.client as mqtt
HOST, PORT = "broker.connectmq.com", 1883
TOPIC = f"testmqtt/bench-{uuid.uuid4().hex[:8]}/latency"
N, RATE = 20000, 500 # messages, messages per second
samples = []
def on_message(client, userdata, msg):
intended = json.loads(msg.payload)["t"]
samples.append((time.perf_counter() - intended) * 1000)
sub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
sub.on_message = on_message
sub.connect(HOST, PORT)
sub.subscribe(TOPIC, qos=1)
sub.loop_start()
pub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
pub.max_queued_messages_set(0) # 0 = unlimited client-side queue
pub.connect(HOST, PORT)
pub.loop_start()
time.sleep(1)
start = time.perf_counter()
for i in range(N):
intended = start + i / RATE # the schedule a real device would follow
delay = intended - time.perf_counter()
if delay > 0:
time.sleep(delay)
pub.publish(TOPIC, json.dumps({"i": i, "t": intended}), qos=1)
time.sleep(5)
q = statistics.quantiles(samples, n=1000) # 999 cut points
print(f"received {len(samples)}/{N}")
print(f"p50 {q[499]:.1f} ms p99 {q[989]:.1f} ms p99.9 {q[998]:.1f} ms max {max(samples):.1f} ms")If the publisher falls behind schedule — because the broker or the network pushed back — every late message carries that delay in its measurement. Compare the output with a version that stamps time.perf_counter() at publish time: if the intended-time p99 is much higher, the broker was stalling and the naive measurement was hiding it.
Are you measuring the broker or the client?
A benchmark machine that runs out of CPU, sockets or network bandwidth produces a plateau that looks exactly like a broker limit. Before you blame the broker, check the load generator:
- CPU on the generator. If one core sits at 100 % (common with single-threaded clients such as one paho network loop handling hundreds of messages per second), the ceiling is yours. Spread clients across processes or machines.
- Round-number plateaus. Connection counts that stop at 1,024, 4,096 or around 28,000–64,000 usually mean file-descriptor or ephemeral-port limits on the client host. Raise
ulimit -nor add source IPs. - Network interface. Multiply message rate by on-the-wire size (the packet size calculator gives you the exact bytes per PUBLISH) and compare with the link capacity — including TLS overhead.
- The double-machine test. Run the same load from two generators at half the rate each. If total throughput rises, the single generator was the bottleneck.
The broker side tells the rest of the story: record its CPU, memory, connection count and message rates (for example from $SYS topics or Prometheus) next to the client-side numbers. A broker at 30 % CPU with a stuck throughput graph is waiting on something else.
Clocks, warm-up and other quiet distortions
- Clock skew. If the publisher and subscriber run on different machines, end-to-end latency includes the difference between their clocks. NTP keeps clocks within a few milliseconds at best — often worse. Measure with publisher and subscriber in one process, or use round trips. More in MQTT clock and timestamp issues.
- Warm-up. The first seconds include TCP and TLS handshakes, JIT compilation and cache misses. Discard a warm-up period or report it separately.
- Too-short runs. A 30-second run can miss periodic events such as persistence snapshots or log rotation. Hold each load level for minutes; use a soak test for hours.
- Lost messages. Latency percentiles calculated only from messages that arrived look great if the slow ones were dropped. Always report
received / sentnext to latency. - QoS mismatch. QoS 0, 1 and 2 have different packet counts and very different costs; never compare results across QoS levels as if they were the same test. See QoS 0 vs 1 vs 2 benchmark.
Spotting a bad result
| Symptom in the data | Likely cause | How to confirm |
|---|---|---|
| Throughput flat while broker CPU is low | Load generator saturated | Check generator CPU; repeat with two generators |
| Received < sent at QoS 0 | Client or broker dropping under pressure | Repeat at QoS 1, compare counts |
| p99 good, max enormous | Single stall (GC, failover, disk) | Plot latency over time, not just a histogram |
| p50 rises steadily during the run | Queue building up (slow consumer) | Watch broker queue / inflight metrics |
| Results vary 30 % between identical runs | Shared or noisy environment | Run three times; test on an isolated broker |
| Latency negative or impossibly low | Clocks on different machines | Measure in one process |
How to report MQTT benchmark results
A number without its setup cannot be reproduced or compared. Report at least this:
| Field | Example |
|---|---|
| Broker, version, deployment | Broker X 2.x, 4 vCPU / 8 GB, single node |
| Client tool and version | emqtt-bench / custom paho-mqtt 2.x script |
| Load generator | 2 machines, same region, CPU peak 55 % |
| Network and transport | Same region, TCP 1883 (or TLS 8883) |
| Protocol and QoS | MQTT 5, QoS 1, clean start |
| Workload | 1,000 publishers, 1 msg/s each, 512 B JSON, 1:1 topics |
| Duration | 5 min warm-up excluded, 20 min measured |
| Results | Throughput sent/received, p50, p99, p99.9, max, errors |
| Raw data | Link to histogram or CSV |
Two habits make reports far more useful:
- Plot latency over time. A histogram hides when the slow messages happened. A time series shows whether the tail is a constant property or a few events.
- Show the saturation point. Instead of one number, run increasing load levels and report the rate at which p99 crosses your target (for example 50 ms). That rate is your usable capacity; the absolute maximum throughput is not.
A quick sanity check before every run
Before starting a long benchmark, connect with the online MQTT client, subscribe to your test topic and publish a handful of messages by hand. If one message takes visibly long or the subscription does not receive anything, fix that first — a load test won't tell you more.
Smoke-test your benchmark topic
Benchmarks are only as good as the questions behind them. Measure from the intended send time, keep an eye on the load generator, report percentiles together with delivery counts and the full setup, and your MQTT numbers will survive the next person who tries to reproduce them — including future you.
Frequently asked questions
Why is the average latency of an MQTT benchmark misleading?
Latency distributions are skewed: most messages are fast and a few are very slow. The average blends both and hides the slow tail that devices and users actually notice. Report p50, p99 and max instead.
What is coordinated omission in an MQTT benchmark?
It happens when the load generator waits for a slow response before sending the next message. The stall is measured once instead of for every message that should have been sent during it, so the tool under-reports latency exactly when the broker is struggling.
How many messages do I need for a reliable p99?
At least several thousand samples per run. With 100 messages, p99 is a single sample. Aim for 10,000 or more for p99 and 100,000 or more if you want to quote p99.9.



