Most "slow MQTT" problems are not the broker. They are a publisher that waits for every acknowledgement, an inflight window of 20 on a 100 ms link, or a subscriber callback that writes to a database before returning. Client-side MQTT performance tuning comes down to a handful of settings: max inflight, how you publish, whether you batch, queue sizes, keep-alive and the TCP socket itself.
This guide explains each knob, shows the setting in common libraries and gives you a script to measure the effect before and after.
Why round-trip time sets the ceiling
With QoS 1, every message needs a PUBACK. If the client allows only N unacknowledged messages, throughput per connection is bounded by roughly:
max messages/s ≈ inflight window ÷ round-trip time
At 20 inflight messages and a 50 ms round trip, that is about 400 messages per second, no matter how fast the broker is. Raise the window to 200 and the ceiling moves to about 4,000. On a LAN with a 1 ms round trip the default window is rarely the limit; across regions or over cellular it almost always is.
QoS 0 has no acknowledgements, so the inflight window does not apply — the limits there are socket buffers, CPU and the broker's per-client limits. QoS 2 needs two round trips per message, so it hits the ceiling sooner. See QoS 0 vs 1 vs 2 benchmarks for the trade-off.
The knobs at a glance
| Setting | What it changes | paho-mqtt (Python) | Eclipse Paho Java | MQTT.js 5 |
|---|---|---|---|---|
| Max inflight | QoS 1/2 messages awaiting ack | max_inflight_messages_set(n), default 20 | setMaxInflight(n), default 10 | No client cap; bound concurrency in your code |
| Outgoing queue | Messages waiting for a free inflight slot | max_queued_messages_set(n), default 0 = unlimited | Internal | Internal; queueQoSZero for offline QoS 0 |
| Keep-alive | Dead-connection detection | connect(..., keepalive=60) | setKeepAliveInterval(s) | keepalive: 60 |
| Callback model | Whether slow handlers block the network loop | One network thread | Callback thread | Event loop |
The broker has the final word. In MQTT 5, the broker's Receive Maximum in CONNACK tells the client how many QoS 1/2 messages it may have in flight; a compliant client must not exceed it, whatever you configure locally. If raising the client setting changes nothing, check that value — flow control and Receive Maximum explains how to read it.
1. Raise max inflight — carefully
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="testmqtt-tuned-pub")
client.max_inflight_messages_set(100) # default 20
client.max_queued_messages_set(10000) # cap memory instead of unlimitedMqttConnectOptions opts = new MqttConnectOptions();
opts.setMaxInflight(100); // default 10
opts.setKeepAliveInterval(30);A bigger window is not free. Every inflight message stays in client memory until acknowledged, and after a reconnect all of them are resent — a large window turns one network blip into a burst of duplicate messages. Increase it step by step and stop when throughput stops improving.
2. Publish asynchronously
The single biggest mistake is waiting for each publish to complete before sending the next:
for reading in readings:
info = client.publish("testmqtt/tuning/data", reading, qos=1)
info.wait_for_publish() # serialises everything on RTTpublish() in paho-mqtt already queues the message and returns immediately. Let it, and track completion in on_publish or by keeping the MQTTMessageInfo objects and checking them at the end.
In MQTT.js 5, publishAsync returns a promise. Awaiting each one in a loop has the same problem; firing all of them at once has no bound at all. A small window gives you both speed and back-pressure:
import mqtt from "mqtt";
const client = await mqtt.connectAsync("mqtt://broker.connectmq.com:1883", {
clientId: `testmqtt-tune-${process.pid}`,
keepalive: 30,
});
const WINDOW = 100; // our own inflight limit
const N = 5000;
const payload = Buffer.alloc(256, "x");
const pending = new Set();
const started = performance.now();
for (let i = 0; i < N; i++) {
const p = client.publishAsync("testmqtt/tuning/js", payload, { qos: 1 })
.finally(() => pending.delete(p));
pending.add(p);
if (pending.size >= WINDOW) await Promise.race(pending);
}
await Promise.all(pending);
const secs = (performance.now() - started) / 1000;
console.log(`${N} msgs in ${secs.toFixed(2)} s = ${(N / secs).toFixed(0)} msg/s`);
await client.endAsync();3. Batch small messages
Each MQTT message carries a fixed header, the topic name and — at QoS 1 — a 4-byte PUBACK coming back. For a 20-byte reading, the topic can be larger than the data. Batching sends one message with many readings:
import json, time
buffer, last_flush = [], time.monotonic()
def add_reading(client, value):
global last_flush
buffer.append({"ts": time.time(), "v": value})
if len(buffer) >= 50 or time.monotonic() - last_flush >= 1.0:
client.publish("testmqtt/tuning/batch", json.dumps(buffer), qos=1)
buffer.clear()
last_flush = time.monotonic()Flush on size or time so a quiet sensor still delivers within a second. The trade-offs are honest ones: latency grows up to the flush interval, a lost batch loses many readings, and consumers must understand arrays. Use the packet size calculator to see how much overhead you actually save, and keep batches well under the broker's maximum packet size. With MQTT 5, topic aliases are a lighter alternative that shrinks long topic names without changing your payload format.
4. Size buffers and queues on purpose
An unlimited outgoing queue hides problems: when the network is slower than your producer, memory grows until the process is killed. A bounded queue turns that into an explicit error you can handle. In paho-mqtt, once max_queued_messages_set(n) is reached, the MQTTMessageInfo returned by publish() has rc == MQTT_ERR_QUEUE_SIZE and nothing is queued — log it and decide whether to drop, sample or slow down.
On the receiving side the equivalent is the subscriber's processing speed. Callbacks run on the client's network thread or event loop; while your on_message is busy, the client is not reading the socket, not sending PUBACKs and not answering keep-alive. Hand work to a queue and return immediately:
import queue, threading
work = queue.Queue(maxsize=10000)
def on_message(client, userdata, msg):
work.put_nowait((msg.topic, msg.payload)) # never block here
def worker():
while True:
topic, payload = work.get()
save_to_database(topic, payload) # slow work off the network thread
threading.Thread(target=worker, daemon=True).start()If the queue fills up anyway, the consumer is genuinely too slow — scale it out with shared subscriptions or see slow consumers. In the Go Paho client, SetOrderMatters(false) lets message handlers run concurrently instead of one after another, at the price of losing ordering guarantees between handlers.
5. Keep-alive and TCP_NODELAY
Keep-alive does not make anything faster. It decides how quickly a dead connection is noticed: the broker drops a client after about 1.5 × the keep-alive interval without traffic. 30–60 seconds suits most connected devices; go longer on battery-powered links. Most libraries skip PINGREQ while other packets are flowing. Details in MQTT keep alive.
Nagle's algorithm can add delay to small messages: the OS holds a small segment back while earlier data is unacknowledged, and combined with delayed ACKs on the other side this produces latency spikes of tens of milliseconds. Disabling it with TCP_NODELAY sends each packet immediately. Whether your library already sets it depends on the library and version; setting it yourself is harmless. In paho-mqtt you can do it in the on_socket_open callback:
import socket
def on_socket_open(client, userdata, sock):
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
client.on_socket_open = on_socket_openMeasure before and after
Tuning without measurement is guesswork. This script publishes a fixed number of QoS 1 messages with a given inflight window and reports the rate at which they were acknowledged:
import os, sys, threading, time
import paho.mqtt.client as mqtt
N = 5000
INFLIGHT = int(sys.argv[1]) if len(sys.argv) > 1 else 20
acked, done = 0, threading.Event()
def on_publish(client, userdata, mid, reason_code, properties):
global acked
acked += 1
if acked == N:
done.set()
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"testmqtt-bench-{os.getpid()}")
client.max_inflight_messages_set(INFLIGHT)
client.on_publish = on_publish
client.connect("broker.connectmq.com", 1883, keepalive=30)
client.loop_start()
time.sleep(1)
start = time.perf_counter()
for _ in range(N):
client.publish("testmqtt/tuning/bench", b"x" * 256, qos=1)
done.wait(timeout=120)
secs = time.perf_counter() - start
print(f"inflight={INFLIGHT}: {acked}/{N} acked in {secs:.2f} s = {acked / secs:.0f} msg/s")
client.loop_stop()
client.disconnect()Run it with python inflight_bench.py 10, then 20, 50, 100, 200. The rate should climb and then flatten; the knee of that curve is the right window for that network path. Repeat from where your devices really are — a result measured next to the broker says little about a device on another continent. While it runs, open the online client and subscribe to testmqtt/tuning/# to confirm messages arrive.
For bigger runs with many clients, move to dedicated tools — the throughput test guide covers them.
Tune in this order: stop waiting on every publish, raise the inflight window until the curve flattens, batch only if per-message overhead still dominates, bound your queues, and keep callbacks fast. Each step is one or two lines of code, and the benchmark script tells you whether it earned its place.
Frequently asked questions
What is max inflight in MQTT?
It is the number of QoS 1 and QoS 2 messages a client may have sent but not yet had acknowledged. When the window is full, further publishes wait in a local queue. paho-mqtt for Python defaults to 20 and Eclipse Paho Java to 10.
Does a shorter keep-alive make MQTT faster?
No. Keep-alive only controls how quickly a dead connection is detected. It does not change throughput or latency of normal messages; a very short value just adds PINGREQ traffic on idle links.
Should I batch MQTT messages?
Batch when you send many small readings at a high rate and the consumer can handle an array. You save per-packet overhead and acknowledgements, at the cost of added latency up to the batch interval and larger loss if one batch is dropped.


