Tutorials

MQTT in C# with MQTTnet 4 and 5: Connect, Subscribe, Reconnect

Build an MQTT client in C# with MQTTnet 4 and 5: connect, subscribe, publish, TLS and WebSockets, a reconnect loop that replaces ManagedMqttClient, and MQTT 5.

Updated · 9 min read

MQTT in C# with MQTTnet 4 and 5: Connect, Subscribe, Reconnect

MQTTnet is the standard MQTT library for .NET. To build a client in C#, create an IMqttClient from the factory, describe the connection with MqttClientOptionsBuilder, attach an ApplicationMessageReceivedAsync handler, then call ConnectAsync, SubscribeAsync and PublishAsync. Since MQTTnet 5 there is no managed client any more, so reconnecting is something you write yourself — it takes about twenty lines.

This tutorial uses MQTTnet 5 and points out every place where version 4 differs, so you can follow it with either. All examples connect to broker.connectmq.com and use topics under testmqtt/dotnet/.

Install and pick a version

Add MQTTnet to a console project
dotnet new console -n MqttDemo && cd MqttDemo
dotnet add package MQTTnet

That installs the latest major version. If you maintain an existing MQTTnet 4 code base, pin it with --version 4.* until you are ready to migrate. The differences you will hit first:

TopicMQTTnet 4MQTTnet 5
Factorynew MqttFactory()new MqttClientFactory()
NamespacesMQTTnet, MQTTnet.ClientMQTTnet
Auto-reconnectManagedMqttClient (separate package)Not included — write a reconnect loop
Payload typeArraySegment<byte> (PayloadSegment)ReadOnlySequence<byte> (Payload)
Builders, events, MQTT 5 options—Mostly unchanged

ConvertPayloadToString() works on both, which is why the examples below use it.

Connect

Program.cs — connect
using MQTTnet;            // v4: also add `using MQTTnet.Client;`
using MQTTnet.Formatter;
using MQTTnet.Protocol;

var factory = new MqttClientFactory();   // v4: new MqttFactory()
using var client = factory.CreateMqttClient();

var options = new MqttClientOptionsBuilder()
    .WithTcpServer("broker.connectmq.com", 1883)
    .WithClientId($"dotnet-{Guid.NewGuid():N}"[..20])
    .WithCredentials(Environment.GetEnvironmentVariable("MQTT_USER"),
                     Environment.GetEnvironmentVariable("MQTT_PASS"))
    .WithProtocolVersion(MqttProtocolVersion.V500)
    .WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
    .WithCleanStart()
    .Build();

var result = await client.ConnectAsync(options, CancellationToken.None);
Console.WriteLine($"Connected: {result.ResultCode}");

On the shared test endpoint you can leave out WithCredentials; on your own broker you pass the username and password from the environment rather than hard-coding them. If the broker refuses the connection, ConnectAsync throws MqttClientConnectingFailedException; its ResultCode (for example BadUserNameOrPassword or NotAuthorized) tells you why. The MQTT reason codes reference lists every value, and the most common exceptions are explained in MQTTnet errors.

Add a Last Will

Most clients should announce when they disappear. Add a will message to the same options builder:

Last Will on the options builder
    .WithWillTopic("testmqtt/dotnet/status")
    .WithWillPayload("offline")
    .WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
    .WithWillRetain()

The broker publishes the will only when the connection ends without a DISCONNECT packet: a crash, a network loss or a keep-alive timeout. A clean DisconnectAsync() suppresses it, so publish a retained online status yourself after connecting and an offline status before a planned shutdown. With MQTT 5, .WithWillDelayInterval(10) gives the client a few seconds to come back before the will goes out, which avoids a flapping status during short network blips. MQTT Last Will covers the details.

Subscribe and handle messages

Register the handler before subscribing, otherwise retained messages delivered right after the SUBACK can arrive before anyone is listening:

Receive messages
client.ApplicationMessageReceivedAsync += e =>
{
    var msg = e.ApplicationMessage;
    Console.WriteLine($"{msg.Topic} (QoS {(int)msg.QualityOfServiceLevel}): {msg.ConvertPayloadToString()}");
    return Task.CompletedTask;
};

var subscribe = factory.CreateSubscribeOptionsBuilder()
    .WithTopicFilter(f => f.WithTopic("testmqtt/dotnet/sensors/#")
                           .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
    .Build();

var subResult = await client.SubscribeAsync(subscribe, CancellationToken.None);
foreach (var item in subResult.Items)
    Console.WriteLine($"{item.TopicFilter.Topic}: {item.ResultCode}");

Always print the subscribe result. A broker that denies a subscription through its ACL does not close the connection; it simply returns a failure code such as NotAuthorized in the SUBACK, and your handler never fires. Checking ResultCode turns a silent failure into a log line.

The handler runs on MQTTnet's internal dispatcher. Keep it short: parse, hand the work off (to a Channel<T>, a queue or a background task) and return. Long-running work inside the handler delays acknowledgements and every message behind it.

Publish

Publish a JSON reading
using System.Text.Json;

var payload = JsonSerializer.Serialize(new { celsius = 21.5, ts = DateTimeOffset.UtcNow });

var message = new MqttApplicationMessageBuilder()
    .WithTopic("testmqtt/dotnet/sensors/lab")
    .WithPayload(payload)
    .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
    .WithRetainFlag(false)
    .WithContentType("application/json")      // MQTT 5 only
    .WithUserProperty("source", "mqttnet-demo") // MQTT 5 only
    .Build();

var pub = await client.PublishAsync(message, CancellationToken.None);
Console.WriteLine($"Publish: {pub.ReasonCode}");

With QoS 1, PublishAsync completes when the PUBACK arrives, so the reason code is the broker's answer. With QoS 0 it completes as soon as the packet is written to the socket. Large payloads may be rejected by the broker's maximum packet size; the packet size calculator shows how big a message really is on the wire.

TLS and WebSockets

Swap the transport in the options builder; everything else stays the same:

TLS on 8883
var tlsOptions = new MqttClientOptionsBuilder()
    .WithTcpServer("broker.connectmq.com", 8883)
    .WithTlsOptions(o => o.UseTls())
    .WithCredentials(Environment.GetEnvironmentVariable("MQTT_USER"),
                     Environment.GetEnvironmentVariable("MQTT_PASS"))
    .Build();
Secure WebSockets on 8084
var wssOptions = new MqttClientOptionsBuilder()
    .WithWebSocketServer(o => o.WithUri("wss://broker.connectmq.com:8084/mqtt"))
    .WithTlsOptions(o => o.UseTls())
    .Build();

The broker certificate is validated against the operating system's trust store. Resist the temptation to add WithCertificateValidationHandler(_ => true) to make a handshake error disappear; find the cause instead, starting with testing the TLS connection from a terminal. Older MQTTnet 4 releases used .WithTls(), which is now obsolete.

Reconnect without ManagedMqttClient

MQTTnet 4 offered ManagedMqttClient, which reconnected and queued messages for you. It is gone in version 5, and the official samples use a simple loop instead. Here is a complete version:

Reconnect loop
client.ConnectedAsync += async e =>
{
    // With clean start the broker forgot our subscriptions; with a persistent session it may not have.
    if (!e.ConnectResult.IsSessionPresent)
        await client.SubscribeAsync(subscribe, CancellationToken.None);
    Console.WriteLine("Connected, session present: " + e.ConnectResult.IsSessionPresent);
};

client.DisconnectedAsync += e =>
{
    Console.WriteLine($"Disconnected ({e.Reason}): {e.Exception?.Message}");
    return Task.CompletedTask;
};

using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, a) => { a.Cancel = true; cts.Cancel(); };

var delay = TimeSpan.FromSeconds(1);
while (!cts.IsCancellationRequested)
{
    try
    {
        if (!await client.TryPingAsync(cts.Token))
        {
            await client.ConnectAsync(options, cts.Token);
            delay = TimeSpan.FromSeconds(1);           // reset backoff after success
        }
    }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        Console.WriteLine($"Reconnect failed: {ex.Message}");
        delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 60));
    }

    try { await Task.Delay(delay + TimeSpan.FromMilliseconds(Random.Shared.Next(500)), cts.Token); }
    catch (OperationCanceledException) { break; }
}

await client.DisconnectAsync();

Three details make this robust. The subscription lives in ConnectedAsync, so it is restored after every reconnect, not only the first. The delay grows exponentially with a little random jitter, so a thousand clients that lose the broker at the same time do not all reconnect in the same second — the reconnect best practices explain why that matters. And TryPingAsync doubles as a health check: it returns false both when the client is not connected and when the connection is half-open.

Buffering while offline

The managed client also queued outgoing messages. If you need that, put a Channel<MqttApplicationMessage> between your code and the client: producers write to the channel, and one consumer task reads from it and calls PublishAsync only when client.IsConnected is true, retrying a message if publishing throws. Bound the channel (Channel.CreateBounded) so a long outage cannot exhaust memory, and decide explicitly whether to drop the oldest or the newest messages when it is full.

MQTT 5 features worth using

Once WithProtocolVersion(MqttProtocolVersion.V500) is set, a few options pay off immediately:

  • Session expiry — .WithSessionExpiryInterval(3600) together with .WithCleanStart(false) keeps subscriptions and queued QoS 1 messages for an hour after a disconnect. Test this against a private broker; shared test endpoints usually do not keep offline sessions.
  • Reason codes and strings — connect, subscribe and publish results carry ResultCode/ReasonCode and often a ReasonString. Log them; they turn "it doesn't work" into "quota exceeded".
  • User properties — key/value metadata such as a trace ID or firmware version, without changing the payload format.
  • Message expiry — .WithMessageExpiryInterval(60) on a message stops stale commands from being delivered to a device that reconnects an hour later.
  • Request/response — .WithResponseTopic() and .WithCorrelationData() give you a standard way to implement RPC over MQTT.

Test it

Run the console app, then open the online MQTT client and subscribe to testmqtt/dotnet/#. You should see the JSON reading arrive on testmqtt/dotnet/sensors/lab. Publish to testmqtt/dotnet/sensors/test from the browser and the C# handler prints it. To test the reconnect loop, disconnect your network for a few seconds and watch the log go from Disconnected to Connected, session present: False followed by a fresh SUBACK.

Watch your MQTTnet client's messages live

If you are building a web API or worker service, the next step is to move this client into a hosted service with dependency injection and health checks — see MQTT in ASP.NET Core.

MQTTnet gives you a small, explicit API: an options builder, three async calls and a handful of events. Add a reconnect loop that resubscribes in ConnectedAsync, log every result code, and the same client works from a console test on the shared endpoint to a production service on your own broker.

Frequently asked questions

Is ManagedMqttClient available in MQTTnet 5?

No. The MQTTnet.Extensions.ManagedClient package is not part of MQTTnet 5. Use a plain IMqttClient with a small reconnect loop (TryPingAsync plus ConnectAsync), resubscribe in the ConnectedAsync handler and, if needed, buffer outgoing messages in a Channel.

What changed between MQTTnet 4 and 5?

The main breaking changes: MqttFactory became MqttClientFactory, client types moved from MQTTnet.Client into the MQTTnet namespace, the managed client extension was dropped and the payload is exposed as a ReadOnlySequence<byte>. Most builder methods kept their names.

How do I use MQTT 5 with MQTTnet?

Call WithProtocolVersion(MqttProtocolVersion.V500) on the client options builder. You then get reason codes in connect, subscribe and publish results, plus session expiry, user properties, message expiry and request/response properties.