Broker Testing

MQTT Throughput Test: Messages per Second Explained

How to run an MQTT throughput test: ingress vs egress msg/s, finding the sustainable rate, spotting backpressure and turning msg/s into bandwidth.

Updated · 9 min read

MQTT Throughput Test: Messages per Second Explained

"Messages per second" is the most quoted MQTT number and the most misunderstood. A broker can accept 50,000 msg/s from publishers and still deliver only half of them, or deliver all of them two seconds late. A useful MQTT throughput test measures the rate the system can sustain — in and out — while latency stays flat and nothing is lost, with your own payloads and topic layout.

This guide defines the numbers, shows how to find the sustainable rate step by step, and explains the backpressure signals that tell you when you have gone past it. For the wider picture of connection counts and latency, see MQTT broker load testing.

Ingress, egress and fan-out

TermMeaningMeasured where
Ingress ratePUBLISH packets per second the broker accepts from publishersPublisher side (sent / acknowledged)
Egress ratePUBLISH packets per second the broker delivers to subscribersSubscriber side (received)
Fan-out factorAverage number of subscribers per messageEgress ÷ ingress
Sustainable rateHighest ingress where egress keeps up, latency is stable and errors stay at zeroBoth sides, over minutes

The fan-out factor is the part people forget. One publisher at 1,000 msg/s with 20 subscribers on the topic is 20,000 msg/s of egress. Broker work is closer to egress than to ingress, so always state both. If you need the extreme cases, see the dedicated fan-out test and fan-in test.

Peak vs sustainable throughput

Every system can absorb a burst above its real capacity for a while: socket buffers, client queues and broker queues fill up and hide the problem. A five-second test measures those buffers, not the broker. The sustainable rate only shows up when you hold each load level long enough for queues to reach a steady state — usually a few minutes.

The method is a step ramp:

  1. Start at a rate you are sure the system handles (for example 20 % of the expected peak).
  2. Hold it for 2–5 minutes and record ingress, egress, p99 latency and errors.
  3. Increase by a fixed step (for example +25 %) and hold again.
  4. Stop when egress stops tracking ingress, p99 latency keeps climbing within a step, or errors appear.
  5. The last clean step is your sustainable rate. Plan production at a comfortable margin below it.

Plotted as offered rate vs latency, you get a flat line followed by a "knee" where latency curves upward. The knee is the number you are looking for — not the maximum rate the load generator could push.

Running the ramp with emqtt-bench

emqtt-bench generates high rates from one machine. Start the subscribers first, then run publishers at increasing rates. The publish interval -I is per client in milliseconds, so the total rate is clients × 1000 / I:

Subscribers — one per publisher topic
docker run --rm -it emqx/emqtt-bench sub \
  -h broker.connectmq.com -p 1883 \
  -c 50 -t 'testmqtt/tput/%i' -q 1 \
  -u "$MQTT_USER" -P "$MQTT_PASS"
Publishers — step 1: 50 clients × 20 msg/s = 1,000 msg/s
docker run --rm -it emqx/emqtt-bench pub \
  -h broker.connectmq.com -p 1883 \
  -c 50 -I 50 -t 'testmqtt/tput/%i' -s 256 -q 1 \
  -u "$MQTT_USER" -P "$MQTT_PASS"

For the next steps lower -I (50 → 40 → 30 → 20 → 10 ms) or add clients. emqtt-bench prints sent and received rates every second; copy them into a spreadsheet per step. Because each publisher has its own subscriber here, the fan-out factor is 1 and egress should equal ingress.

A Python probe for gaps and lag

Rate counters hide two things: which messages went missing and how late the rest arrived. Run this small paho-mqtt 2.x probe next to the benchmark. It publishes sequence-numbered, timestamped messages at a modest rate and reports gaps and lag every second — the probe's lag rising while the bench runs is your backpressure signal:

tput_probe.py
import json, time, uuid
import paho.mqtt.client as mqtt

HOST, PORT = "broker.connectmq.com", 1883
TOPIC = f"testmqtt/tput/probe-{uuid.uuid4().hex[:6]}"
RATE = 100                      # probe messages per second
stats = {"n": 0, "last": -1, "gaps": 0, "lag": []}

def on_message(client, userdata, msg):
    m = json.loads(msg.payload)
    if m["i"] > stats["last"] + 1:
        stats["gaps"] += m["i"] - stats["last"] - 1
    stats["last"] = max(stats["last"], m["i"])
    stats["n"] += 1
    stats["lag"].append((time.perf_counter() - m["t"]) * 1000)

def client(name):
    c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"{name}-{uuid.uuid4().hex[:6]}")
    c.connect(HOST, PORT)
    c.loop_start()
    return c

sub = client("probe-sub")
sub.on_message = on_message
sub.subscribe(TOPIC, qos=1)
pub = client("probe-pub")
time.sleep(1)

i, next_report = 0, time.time() + 1
while True:
    pub.publish(TOPIC, json.dumps({"i": i, "t": time.perf_counter()}), qos=1)
    i += 1
    time.sleep(1 / RATE)
    if time.time() >= next_report:
        lag = sorted(stats["lag"]) or [0]
        print(f"rx {stats['n']:>5}/s  gaps {stats['gaps']}  "
              f"lag p50 {lag[len(lag)//2]:.1f} ms  p99 {lag[int(len(lag)*0.99)-1]:.1f} ms")
        stats.update(n=0, lag=[])
        next_report += 1

A single Python process will not generate tens of thousands of messages per second — that is the bench tool's job. The probe is a thermometer, not a heater.

Recognising backpressure

MQTT has no global flow-control signal; backpressure shows up as queues growing somewhere. Learn where to look:

SignalWhere it livesWhat it means
Publisher's sent rate drops below its targetClient inflight window full (QoS 1/2)PUBACKs come back slower than you publish — the broker or the path is saturated
Probe lag climbs steadily during a stepBroker or subscriber queueEgress cannot keep up; you are above the sustainable rate
Probe gaps at QoS 0Broker dropping messages for slow clientsQueue limit reached; the broker sheds load
Subscriber disconnects with queue or quota errorsBroker per-client limitsSee message queue full and slow consumers
Load generator CPU at 100 %Your test machineThe result measures the client, not the broker

For QoS 1 and 2 the inflight window is the key limiter: a client can only have so many unacknowledged messages outstanding (paho's max_inflight_messages_set(), MQTT 5's Receive Maximum). With 20 messages in flight and a 50 ms round trip, one connection tops out near 400 msg/s no matter how fast the broker is. That is why throughput tests over long-distance links look worse than in-region ones, and why more connections — not a faster loop — raise the ceiling. Receive Maximum and flow control explains the mechanism.

From msg/s to bandwidth

Throughput is also a network budget. An MQTT PUBLISH costs roughly: fixed header (2–5 bytes) + topic length + 2 bytes + packet identifier (2 bytes at QoS > 0) + MQTT 5 properties + payload. Then add TCP/IP headers and, if used, TLS record overhead. QoS 1 adds a 4-byte PUBACK in the other direction.

Worked example: 256-byte payload, 20-byte topic, QoS 1 → about 285 bytes per PUBLISH before TCP/IP. At 5,000 msg/s egress that is about 1.4 MB/s, or roughly 11–12 Mbit/s of MQTT payload traffic, before IP and TLS overhead. Small messages are dominated by overhead, so batching readings into one message often raises effective throughput more than any broker tuning. The packet size calculator does the arithmetic for your exact topic and payload.

Writing up the result

A throughput result is only reusable if it states its conditions. Record at least: broker version and configuration, QoS, payload size, number of topics, fan-out factor, number of publisher and subscriber connections, TLS on or off, test duration per step, and the region of the load generator. Then report the sustainable ingress and egress rates with their p99 latency, not the peak. When you compare runs later, interpreting benchmark results covers the common traps.

Before ramping, smoke-test your topics with the online MQTT client to confirm credentials and ACLs — a permission error under load looks exactly like a throughput problem.

A throughput test is a search for the knee, not a race to the biggest number. Ramp in steps, hold each step long enough, watch lag and gaps instead of only counters, and report egress alongside ingress — that is the number you can plan capacity around.

Frequently asked questions

What is a good MQTT throughput number?

There is no universal number: it depends on payload size, QoS, fan-out, TLS, persistence and hardware. The useful figure is the highest rate your broker sustains with your own traffic shape while latency stays flat and nothing is dropped.

Why is my received rate lower than my sent rate?

Either messages are queued somewhere (inflight windows, broker queues, client buffers) or they are being dropped. If the gap grows over time, you are above the sustainable rate. At QoS 0, a growing gap usually means drops.

Does QoS affect MQTT throughput?

Yes. QoS 1 adds a PUBACK per message per hop and QoS 2 adds three extra packets, and both are limited by the inflight window. Expect lower peak rates at higher QoS, especially over high-latency links.