MQTTnet is the most widely used MQTT library for .NET, and it covers everything from a quick console test to a production service. In this tutorial you will build a C# publisher and a subscriber with MQTTnet, use MQTT 5 properties, encrypt the connection with TLS, and add reconnect logic you can trust. Every example runs against the free public broker broker.connectmq.com, so you can follow along without installing anything except the .NET SDK.
What You Need
- .NET 8 SDK or newer (
dotnet --versionto check) - Any editor: Visual Studio, Rider or VS Code with the C# extension
- A terminal with outbound access to port 8883 (TLS) or 1883 (plain TCP)
You do not need an account for the first part. The public MQTT test broker at broker.connectmq.com accepts anonymous clients with any non-empty, unique client ID. Remember that the public area is readable by everyone, so use a random topic prefix and never send real data there.
MQTTnet Versions: Read This First
MQTTnet had a major API cleanup in version 5. If you copy snippets from older blog posts, you will hit compile errors. The most important differences:
| Topic | MQTTnet 4.x | MQTTnet 5.x |
|---|---|---|
| Factory class | MqttFactory |
MqttClientFactory |
| Namespaces | MQTTnet.Client, MQTTnet |
Mostly flattened into MQTTnet |
| Managed client (auto reconnect) | MQTTnet.Extensions.ManagedClient |
Removed, write your own loop |
| Payload type | byte[] / ArraySegment<byte> |
ReadOnlySequence<byte> |
| TLS setup | WithTls() (obsolete) or WithTlsOptions |
WithTlsOptions(o => o.UseTls()) |
This guide targets MQTTnet 5. The concepts carry over to 4.x, but class names differ as shown above. The official MQTTnet repository has samples for every feature if you need to go deeper.
Create the Projects
Create one solution with two console apps:
mkdir MqttNetDemo && cd MqttNetDemo
dotnet new console -n Publisher
dotnet new console -n Subscriber
dotnet add Publisher package MQTTnet
dotnet add Subscriber package MQTTnet
Pick a unique topic prefix now and use it in both apps. Something like connectmq/demo/k7f3q9/ is fine. Collisions on a shared broker are a classic source of "why am I receiving messages I never sent?" confusion.
Your First MQTTnet Subscriber
Replace Subscriber/Program.cs with the code below. It connects over TLS on port 8883, uses MQTT 5, subscribes with QoS 1 and prints every message along with its MQTT 5 properties.
using System.Text;
using MQTTnet;
using MQTTnet.Formatter;
using MQTTnet.Protocol;
const string Prefix = "connectmq/demo/k7f3q9";
var factory = new MqttClientFactory();
using var client = factory.CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("broker.connectmq.com", 8883)
.WithClientId($"sub-{Guid.NewGuid():N}")
.WithProtocolVersion(MqttProtocolVersion.V500)
.WithTlsOptions(o => o.UseTls())
.WithCleanStart(true)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
.Build();
client.ApplicationMessageReceivedAsync += e =>
{
var msg = e.ApplicationMessage;
var payload = msg.ConvertPayloadToString();
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] {msg.Topic} (QoS {(int)msg.QualityOfServiceLevel}, retain={msg.Retain})");
Console.WriteLine($" payload: {payload}");
if (!string.IsNullOrEmpty(msg.ContentType))
Console.WriteLine($" content-type: {msg.ContentType}");
foreach (var p in msg.UserProperties ?? [])
Console.WriteLine($" user-property: {p.Name}={p.Value}");
return Task.CompletedTask;
};
var result = await client.ConnectAsync(options, CancellationToken.None);
Console.WriteLine($"Connected: {result.ResultCode}");
var subscribe = new MqttClientSubscribeOptionsBuilder()
.WithTopicFilter(f => f
.WithTopic($"{Prefix}/#")
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
.Build();
var subResult = await client.SubscribeAsync(subscribe, CancellationToken.None);
foreach (var item in subResult.Items)
Console.WriteLine($"Subscribed {item.TopicFilter.Topic}: {item.ResultCode}");
Console.WriteLine("Waiting for messages. Press Enter to exit.");
Console.ReadLine();
await client.DisconnectAsync(new MqttClientDisconnectOptionsBuilder()
.WithReason(MqttClientDisconnectOptionsReason.NormalDisconnection)
.Build());
Run it with dotnet run --project Subscriber. You should see Connected: Success and a granted QoS for the subscription. Note that the handler is registered before ConnectAsync; if you subscribe with a persistent session, messages can arrive the instant the connection opens, and a late handler would miss them.
Publishing with MQTTnet and MQTT 5 Properties
Now Publisher/Program.cs. It sends a JSON reading every two seconds with a content type, a user property and a message expiry interval, plus one retained status message.
using System.Text.Json;
using MQTTnet;
using MQTTnet.Formatter;
using MQTTnet.Protocol;
const string Prefix = "connectmq/demo/k7f3q9";
var factory = new MqttClientFactory();
using var client = factory.CreateMqttClient();
var options = new MqttClientOptionsBuilder()
.WithTcpServer("broker.connectmq.com", 8883)
.WithClientId($"pub-{Guid.NewGuid():N}")
.WithProtocolVersion(MqttProtocolVersion.V500)
.WithTlsOptions(o => o.UseTls())
.WithWillTopic($"{Prefix}/sensor-1/status")
.WithWillPayload("offline")
.WithWillRetain(true)
.WithWillQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
.Build();
await client.ConnectAsync(options, CancellationToken.None);
// Retained "online" status: new subscribers see it immediately.
await client.PublishAsync(new MqttApplicationMessageBuilder()
.WithTopic($"{Prefix}/sensor-1/status")
.WithPayload("online")
.WithRetainFlag(true)
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
.Build());
var rnd = new Random();
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
while (!cts.IsCancellationRequested)
{
var reading = new
{
deviceId = "sensor-1",
temperature = Math.Round(20 + rnd.NextDouble() * 5, 2),
ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
var message = new MqttApplicationMessageBuilder()
.WithTopic($"{Prefix}/sensor-1/telemetry")
.WithPayload(JsonSerializer.Serialize(reading))
.WithContentType("application/json")
.WithUserProperty("firmware", "1.4.2")
.WithMessageExpiryInterval(60)
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
.Build();
var pub = await client.PublishAsync(message, cts.Token);
Console.WriteLine($"Published {reading.temperature} -> {pub.ReasonCode}");
try { await Task.Delay(2000, cts.Token); } catch (TaskCanceledException) { }
}
await client.DisconnectAsync();
Start the subscriber first, then dotnet run --project Publisher. The subscriber prints the JSON, the application/json content type and the firmware user property. Stop the publisher with Ctrl+C and the retained status stays online because the disconnect was clean. Kill the process instead (close the terminal window) and the broker publishes the Last Will, flipping the retained status to offline. That pattern is the simplest reliable presence indicator in MQTT.
The PublishAsync result contains a reason code for QoS 1 and 2. With MQTT 5 you can read meaningful codes such as QuotaExceeded (0x97) instead of a silent failure, which is worth logging in any real application. If you want a refresher on what each level guarantees, see MQTT QoS levels explained.
Reconnecting Without the Managed Client
Because MQTTnet 5 removed the managed client, you own reconnection. A small, robust pattern is a background loop that checks IsConnected and uses TryPingAsync to detect half-open connections:
async Task KeepConnectedAsync(IMqttClient client, MqttClientOptions options,
Func<Task> onConnected, CancellationToken ct)
{
var delay = TimeSpan.FromSeconds(1);
while (!ct.IsCancellationRequested)
{
try
{
if (!await client.TryPingAsync(ct))
{
await client.ConnectAsync(options, ct);
await onConnected(); // resubscribe here
Console.WriteLine("Reconnected");
delay = TimeSpan.FromSeconds(1);
}
}
catch (Exception ex)
{
Console.WriteLine($"Connect failed: {ex.Message}");
delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 2, 60));
}
await Task.Delay(delay + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500)), ct);
}
}
Three details matter here. First, resubscribe in onConnected whenever the session was not resumed; check result.IsSessionPresent from ConnectAsync if you use persistent sessions. Second, exponential backoff with jitter prevents a fleet of clients from reconnecting in lockstep after a network blip. Third, keep the client ID stable across reconnects if you rely on a persistent session; generate it once at startup, not inside the loop.
Common MQTTnet Errors and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
MqttCommunicationTimedOutException on connect |
Port 8883 or 1883 blocked by firewall or corporate proxy | Test with Test-NetConnection broker.connectmq.com -Port 8883; try the other port |
AuthenticationException during TLS handshake |
Old OS root store or system clock wrong | Update the OS trust store and sync time; the broker uses a Let's Encrypt certificate |
Connect returns NotAuthorized / BadUserNameOrPassword |
Wrong credentials, or client ID not the one bound to the credentials | Copy username, password and client ID exactly from the portal |
| Handler never fires | Handler registered after subscribe, or topic filter typo | Register before connect; print subResult.Items |
| Client disconnects every few seconds | Two processes using the same client ID | The broker kicks the older session; make IDs unique |
QuotaExceeded (0x97) |
Plan or public broker limit reached | Slow down, or move to a plan with higher limits |
Be aware of the public broker limits while testing: 64 KB per message, 10 messages per second per client, 10 subscriptions per client and up to 8 topic levels. The publisher above stays far below them.
Moving to a Private Broker
The public broker is ideal for experiments, but production traffic belongs on a private instance with credentials. With a managed MQTT broker from ConnectMQ, you sign up, choose a plan, create a broker and receive a generated username, password and client ID. The code change is three lines:
var options = new MqttClientOptionsBuilder()
.WithTcpServer("YOUR_BROKER_HOST", 8883)
.WithClientId("YOUR_CLIENT_ID")
.WithCredentials("YOUR_USERNAME", "YOUR_PASSWORD")
.WithProtocolVersion(MqttProtocolVersion.V500)
.WithTlsOptions(o => o.UseTls())
.Build();
Keep credentials out of source code: read them from environment variables, dotnet user-secrets during development, or your platform's secret store in production. Client IDs on ConnectMQ are bound to the issued credentials, so a leaked password alone is not enough to impersonate a different client, but you should still rotate anything that ends up in a repository.
The free Serverless plan allows up to 25 connections, QoS up to 1 and 256 KB messages, which is plenty for development. Paid Serverless raises that to 1,000 connections with QoS 2, 1 MB messages and shared subscriptions, and Dedicated gives you an isolated cluster.
Where to Go Next with MQTTnet
Once the console apps work, the same client fits into larger .NET applications:
- Host the subscriber as a long-running service with dependency injection: see the ASP.NET Core MQTT background service guide.
- Build a cross-platform mobile and desktop UI with the .NET MAUI MQTT app tutorial.
- Drive 3D scenes from live data with a Unity MQTT client.
- Compare MQTTnet with libraries in other languages in the MQTT client libraries overview.
If you are still deciding where these clients should connect, the guide to choosing a cloud MQTT broker explains the trade-offs between self-hosting and a hosted service.
Get Started with ConnectMQ
Your MQTTnet code is ready for production the moment it points at a private broker. Create one at app.connectmq.com on the free Serverless plan, no credit card required, copy the generated credentials into the options builder, and watch your messages arrive in the portal's live topic explorer and in-browser MQTT client.
Frequently Asked Questions
Is MQTTnet free for commercial use?
Yes. MQTTnet is open source under the MIT license and is maintained under the .NET Foundation, so you can use it in commercial products.
Does MQTTnet support MQTT 5?
Yes. Set WithProtocolVersion(MqttProtocolVersion.V500) to use MQTT 5 features such as user properties, content type, message expiry, reason codes and session expiry. ConnectMQ supports both MQTT 3.1.1 and 5.0.
What happened to the MQTTnet managed client?
It was removed in MQTTnet 5. Use a reconnect loop like the one in this tutorial, or stay on MQTTnet 4.x if you depend heavily on the managed client and cannot migrate yet.
Can MQTTnet connect over WebSocket?
Yes. Use WithWebSocketServer(o => o.WithUri("wss://broker.connectmq.com:8084/mqtt")) instead of WithTcpServer. This helps when only HTTPS traffic is allowed out of a network.



