Simulate 1000 MQTT Clients in C# (mqtt-churn, Part 1)
Simulate 1000 MQTT Clients in C# (mqtt-churn, Part 1)
mqtt-churn series, part 1. Video: ๐นSimulate 1000 MQTT Clients in C# ยท Code:
mqtt-churnat tagb1
In MQTT Retained Messages and Last Will, one sensor lost power and an AI agent learned about it from a retained offline Will within a heartbeat. One sensor is a demo. A production line loses power all at once: does the broker fire 500 Wills on time, and how long is the agent blind?
No tool answered that, so we're building one in the open: mqtt-churn. Part 1 builds the fleet, and the first measurement taught three lessons before we killed a single client.
Table of Contents
- Why not an existing tool?
- The fleet in 75 lines
- Paced connects, not a burst
- Leaving the broker clean
- Percentiles you can explain
- The numbers, and what they actually measured
- Key takeaways
Why not an existing tool?
Before writing code, I checked what already exists:
| Tool | What it does well | Last Will latency? |
|---|---|---|
| emqtt-bench | Very fast conn / sub / pub benchmarks in Erlang |
No Will option at all |
| NBomber.MQTT | Load-test steps over MQTTnet (experimental plugin) | You would build it yourself |
| HiveMQ Swarm | Commercial scenario runner | Trial is limited to 25 clients |
All three measure throughput and connections. None of them reports what the AI agent cares about: the time between a device dropping and a subscriber seeing its retained offline. That is the gap.
The fleet in 75 lines
Every simulated device is a real IMqttClient with a retained Last Will on its own topic, churn/{runId}/{i}/status:
var options = new MqttClientOptionsBuilder() .WithTcpServer(o.Broker, o.Port) .WithClientId($"churn-{o.RunId}-{i}") .WithProtocolVersion(MqttProtocolVersion.V500) .WithKeepAlivePeriod(TimeSpan.FromSeconds(o.KeepAlive)) .WithWillTopic(topic) .WithWillPayload("offline") .WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) .WithWillRetain() .Build(); await client.ConnectAsync(options, ct); await client.PublishStringAsync(topic, "online", MqttQualityOfServiceLevel.AtLeastOnce, retain: true, ct);
The measured time is CONNECT plus the QoS 1 publish of the retained online, because that is when the device becomes visible to the agent.
Paced connects, not a burst
A thousand clients connecting in the same millisecond tests your laptop's SYN queue, not a broker. Real fleets arrive at a rate. Client i starts at i / rate seconds:
public static TimeSpan StartAt(int i, double rate) => TimeSpan.FromSeconds(i / rate); var wait = StartAt(i, o.Rate) - clock.Elapsed; if (wait > TimeSpan.Zero) await Task.Delay(wait, ct);
It is a pure function, so it's the first thing under test.
Leaving the broker clean
A load test that leaves 1000 retained online messages behind poisons the next run. On exit, each client clears its retained topic with an empty retained payload, then sends a normal DISCONNECT, so the broker does not fire its Will:
await client.PublishBinaryAsync(topic, [], MqttQualityOfServiceLevel.AtLeastOnce, retain: true); await client.DisconnectAsync(new MqttClientDisconnectOptions());
After every run, mosquitto_sub -t 'churn/#' times out with nothing to show.
Percentiles you can explain
Averages hide the slow tail that pages people at 3 am. mqtt-churn reports nearest-rank percentiles, defined as the smallest value with at least p% of samples at or below it:
public static double Percentile(IReadOnlyList<double> sorted, double p) { if (sorted.Count == 0) throw new ArgumentException("No samples.", nameof(sorted)); var rank = (int)Math.Ceiling(p / 100 * sorted.Count); return sorted[Math.Clamp(rank, 1, sorted.Count) - 1]; }
There is no interpolation, so every reported number is a latency one real client actually had. It prints a text histogram too, and --csv dumps one row per client.
The numbers, and what they actually measured
Setup: Mosquitto 2.1.2 in Podman 5.8 on Windows 11, MQTTnet 5.2, .NET 10, Core Ultra 7 265H.
| Run | Connected | p50 | p95 | p99 |
|---|---|---|---|---|
100 @ 50/s, --broker localhost |
100/100 | 2097 ms | 2107 ms | 2150 ms |
100 @ 50/s, 127.0.0.1 |
100/100 | 45.8 ms | 50.1 ms | 65.5 ms |
1000 @ 200/s, 127.0.0.1 |
1000/1000 | 47.3 ms | 104.5 ms | 226.2 ms |
3000 @ 500/s, 127.0.0.1 |
1989/3000 | 50.5 ms | 64.1 ms | 85.3 ms |
| 1000 @ 200/s, inside the compose network | 1000/1000 | 0.9 ms | 3.3 ms | 36.8 ms |
| 5000 @ 500/s, inside the compose network | 5000/5000 | 0.7 ms | 1.9 ms | 27.2 ms |
None of the first four rows is a Mosquitto number:
localhostcosts 2 seconds per client. On Windows,localhostresolves to::1first. Nothing answers the container port there, so the OS retries for about 2 s before falling back to IPv4. That's why the tool defaults to127.0.0.1.- The ~45 ms floor is the harness. From Windows, CONNECT alone takes 2โ3 ms but the QoS 1 PUBACK round trip takes about 41 ms. The same binary inside the container network gets its PUBACK in about 0.2 ms. The client already sets
NoDelay, and Mosquitto'sset_tcp_nodelaychanged nothing, so the delay sits in Podman's Windows port forwarder. - The 1989-connection ceiling is the forwarder too. From Windows, exactly 1989 clients get in. The rest see
Connection closed, and the broker logs nothing (itsnofilelimit is 65536). Inside the network, 5000 of 5000 connect.
So real broker numbers come from running the fleet next to the broker:
dotnet publish src/MqttChurn -c Release -r linux-x64 --self-contained false -o out podman run --rm --network mqtt-churn_default -v ${PWD}/out:/app -w /app ` mcr.microsoft.com/dotnet/sdk:10.0 dotnet MqttChurn.dll --broker mosquitto --clients 5000 --rate 500
One more: the very first run after a cold start was about 10ร slower (p50 748 ms against 52โ56 ms warm). Throw the first run away.
Key takeaways
- Pace your connects. A burst measures your machine; a rate measures the system.
- Clean up retained state with an empty retained payload before a normal DISCONNECT.
- Report percentiles, not averages, and keep every raw sample.
- Measure your harness first. Here, DNS resolution and a port forwarder cost more than the broker did.
Next up, Part 2: Server-Sent Events in C# + React: Watch 5,000 Devices Die. We kill the fleet and watch it die, live in the browser: an ASP.NET Core watcher streams the fleet's state over Server-Sent Events to a React heatmap. On the first run, three deaths out of four never showed up.
