Chaos testing MQTT means breaking the network and the broker on purpose — latency, packet loss, partitions, restarts — while measuring whether your clients recover on their own. Most MQTT bugs that reach production are recovery bugs: a client that never notices a dead connection, a subscription that is not restored, a QoS 1 message lost because the session was not persistent. This guide shows the tools (tc netem, iptables, Toxiproxy, docker) and a probe script that turns each experiment into numbers.
Define what "recovered" means first
A chaos experiment without a pass criterion is just an outage. Write down the steady state your system must return to:
| Invariant | Typical target |
|---|---|
| Client detects a dead connection | Within about 1.5 × keep-alive |
| Client reconnects after the fault heals | Within the backoff maximum (for example 16–30 s) |
| Subscriptions are active again | Immediately after reconnect |
| QoS 1 messages acknowledged by the broker | None missing after recovery |
| Duplicates | Allowed at QoS 1, handled by the consumer |
| Last Will | Published when a client disappears uncleanly |
Two mechanisms do most of the work: the keep-alive interval, which bounds how long a dead connection goes unnoticed, and persistent sessions, which let the broker hold QoS 1 messages for a subscriber while it is away.
The probe: measure loss, duplicates and reconnects
This paho-mqtt 2.x script runs a publisher and a subscriber with a persistent MQTT 5 session. It publishes a numbered message every 100 ms at QoS 1 and reports every five seconds how many acknowledged messages have not arrived yet (missing), how many arrived twice (dups), reconnect count and worst latency.
import json, os, threading, time, uuid
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties
HOST = os.getenv("MQTT_HOST", "localhost")
PORT = int(os.getenv("MQTT_PORT", "1883"))
RUN = uuid.uuid4().hex[:8]
TOPIC = f"testmqtt/chaos/{RUN}/seq"
lock = threading.Lock()
seen, sent = set(), [] # sent: (seq, MQTTMessageInfo)
stats = {"dups": 0, "reconnects": 0, "max_ms": 0.0}
def on_connect(client, userdata, flags, reason_code, properties):
print(f"[{userdata['role']}] connected: {reason_code}, session present={flags.session_present}")
if userdata["ever"]:
stats["reconnects"] += 1
userdata["ever"] = True
if userdata["role"] == "sub":
client.subscribe(TOPIC, qos=1)
def on_disconnect(client, userdata, flags, reason_code, properties):
print(f"[{userdata['role']}] disconnected: {reason_code}")
def on_message(client, userdata, msg):
data = json.loads(msg.payload)
with lock:
if data["seq"] in seen:
stats["dups"] += 1
seen.add(data["seq"])
stats["max_ms"] = max(stats["max_ms"], (time.time() - data["t"]) * 1000)
def make(role, session_expiry):
props = Properties(PacketTypes.CONNECT)
props.SessionExpiryInterval = session_expiry
c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"chaos-{role}-{RUN}",
protocol=mqtt.MQTTv5, userdata={"role": role, "ever": False})
c.on_connect, c.on_disconnect, c.on_message = on_connect, on_disconnect, on_message
c.reconnect_delay_set(min_delay=1, max_delay=16)
c.connect_async(HOST, PORT, keepalive=10, properties=props)
c.loop_start()
return c
sub = make("sub", session_expiry=600) # session survives outages up to 10 minutes
pub = make("pub", session_expiry=0)
time.sleep(2)
seq, next_report = 0, time.time() + 5
while True:
info = pub.publish(TOPIC, json.dumps({"seq": seq, "t": time.time()}), qos=1)
sent.append((seq, info))
seq += 1
if time.time() >= next_report:
acked = {s for s, i in sent if i.is_published()}
with lock:
print(f"{time.strftime('%H:%M:%S')} sent={seq} acked={len(acked)} "
f"missing={len(acked - seen)} dups={stats['dups']} "
f"reconnects={stats['reconnects']} max_latency={stats['max_ms']:.0f}ms")
stats["max_ms"] = 0.0
next_report += 5
time.sleep(0.1)While the network is down, paho keeps QoS 1 messages in its in-memory queue and sends them after reconnecting, so sent keeps growing while acked pauses. During an outage missing may rise briefly; after recovery it must return to 0. A permanent non-zero value means the broker acknowledged messages that were later lost — usually a session that did not survive.
Experiment 1: latency and packet loss with tc netem
On Linux, tc netem degrades an interface. Run it inside a VM or a container with --cap-add NET_ADMIN so you do not slow down your whole workstation.
IFACE=eth0
sudo tc qdisc add dev $IFACE root netem delay 300ms 100ms loss 5% # bad cellular
sudo tc qdisc change dev $IFACE root netem delay 2000ms loss 30% # nearly unusable
sudo tc qdisc del dev $IFACE root # back to normalExpected: at 300 ms and 5 % loss, TCP retransmits hide the loss — latency rises but there are no reconnects and missing stays at 0. At 2 s and 30 % loss, PINGRESP may arrive too late and clients start reconnecting; check that they settle once the rule is removed. For realistic cellular profiles see testing MQTT under cellular network conditions.
Experiment 2: network partitions with iptables
There are two very different ways for a network to fail, and clients often handle only one of them:
# Blackhole: packets silently vanish. Only keep-alive can detect this.
sudo iptables -A OUTPUT -p tcp --dport 1883 -j DROP
# ...wait 60 s, then heal:
sudo iptables -D OUTPUT -p tcp --dport 1883 -j DROP
# Hard failure: the client gets a TCP reset immediately.
sudo iptables -A OUTPUT -p tcp --dport 1883 -j REJECT --reject-with tcp-reset
sudo iptables -D OUTPUT -p tcp --dport 1883 -j REJECT --reject-with tcp-resetWith a 10-second keep-alive, the probe should log disconnected roughly 10–20 seconds into a blackhole, retry with backoff while it lasts, and reconnect with session present=True once it heals. On the broker side, other clients should receive the probe's Last Will after about 1.5 × keep-alive. A client that stays "connected" for minutes during a blackhole has keep-alive disabled or set far too high. Testing QoS with a network cut goes deeper into what each QoS level guarantees here.
Experiment 3: per-connection faults with Toxiproxy
Toxiproxy is a TCP proxy whose behaviour you change through an HTTP API. It works on macOS and Windows too, affects only connections that go through it, and is easy to script in CI.
docker run -d --name toxiproxy -p 8474:8474 -p 21883:21883 ghcr.io/shopify/toxiproxy
curl -s -X POST localhost:8474/proxies \
-d '{"name":"mqtt","listen":"0.0.0.0:21883","upstream":"broker.connectmq.com:1883"}'
# Point the probe at the proxy
MQTT_HOST=localhost MQTT_PORT=21883 python chaos_probe.pyThen inject faults from a second terminal:
API=localhost:8474/proxies/mqtt
# 1.5 s latency with 500 ms jitter
curl -s -X POST $API/toxics -d '{"name":"lag","type":"latency","attributes":{"latency":1500,"jitter":500}}'
curl -s -X DELETE $API/toxics/lag
# Blackhole: data stops, connection stays open until the toxic is removed
curl -s -X POST $API/toxics -d '{"name":"cut","type":"timeout","attributes":{"timeout":0}}'
curl -s -X DELETE $API/toxics/cut
# Connection reset by peer
curl -s -X POST $API/toxics -d '{"name":"rst","type":"reset_peer","attributes":{"timeout":0}}'
curl -s -X DELETE $API/toxics/rst
# Take the whole "network" down and up again
curl -s -X POST $API -d '{"enabled":false}'; sleep 30; curl -s -X POST $API -d '{"enabled":true}'The timeout toxic with 0 is the Toxiproxy equivalent of an iptables DROP; reset_peer matches REJECT. Testing MQTT with Toxiproxy covers bandwidth limits, slicing and test-framework integration.
Experiment 4: broker restarts and crashes
Restart experiments need a broker you run yourself. A local Mosquitto container is enough to find most client-side bugs:
docker run -d --name chaos-broker -p 1883:1883 eclipse-mosquitto:2 mosquitto -c /mosquitto-no-auth.conf
MQTT_HOST=localhost MQTT_PORT=1883 python chaos_probe.py # in another terminal
docker restart chaos-broker # graceful: SIGTERM, then start
docker kill --signal=KILL chaos-broker && docker start chaos-broker # crashWhat you should observe:
- Without persistence (the default config above), the broker forgets every session. The probe reconnects with
session present=False, re-subscribes, and messages published during the restart can be permanentlymissing. - With
persistence trueand a volume for the data directory, sessions and queued messages survive a graceful restart. After aKILL, anything since the last autosave can still be lost — check whether that matches what your design promises. - Clients must not reconnect in lockstep. With hundreds of clients, a restart turns into a connection spike. Backoff with jitter spreads it out; see MQTT reconnect storms.
For clustered brokers, replace the restart with stopping one node at a time and verify that clients fail over. MQTT reconnect testing has more client-side scenarios.
An experiment plan you can repeat
| # | Fault | Tool | Pass criteria |
|---|---|---|---|
| 1 | 300 ms ± 100 ms, 5 % loss, 5 min | tc netem | No reconnects, missing = 0 |
| 2 | 2 s latency, 30 % loss, 2 min | tc netem | Reconnects allowed, missing = 0 after restore |
| 3 | 60 s blackhole | iptables DROP / timeout toxic | Detected within ~1.5 × keep-alive, LWT fired, full recovery |
| 4 | Connection reset | REJECT / reset_peer | Reconnect within the backoff minimum |
| 5 | Graceful broker restart | docker restart | session present=True with persistence, missing = 0 |
| 6 | Broker crash | docker kill KILL | Loss matches documented guarantees |
Run the plan before every major client or broker release, keep the probe output as an artifact, and add a new row whenever a real incident teaches you a new failure mode.
Watch probe messages arrive in your browser
Chaos testing MQTT is mostly about recovery: detecting the failure fast, reconnecting gently and losing nothing you promised to deliver. With netem, iptables, Toxiproxy and a restartable broker you can reproduce nearly every outage on a laptop, and the probe tells you in numbers whether your clients pass.
Frequently asked questions
What is chaos testing for MQTT?
It is deliberately breaking the things MQTT depends on, such as the network path, packet delivery and the broker process, while a probe measures whether clients reconnect, restore their subscriptions and deliver every QoS 1 message. The goal is to find recovery bugs before real outages do.
Do I need Kubernetes or a chaos platform to start?
No. tc netem and iptables on a Linux machine or container, Toxiproxy for per-connection faults and docker restart for the broker cover the important failure modes. Chaos platforms help later, when you want scheduled experiments across many services.
Is it normal to see duplicate messages after a network failure?
Yes, at QoS 1. A message whose PUBACK was lost is sent again after reconnect, so the subscriber can receive it twice. That is correct behaviour; your consumer must tolerate it, for example by de-duplicating on a message ID or sequence number.


