Skip to content

Migrating from MQTTnet ​

This maps the MQTTnet patterns you already know to their Pulse equivalents. The headline difference: MQTTnet gives you a low-level IMqttClient and a separate IManagedMqttClient for reconnect/queueing; Pulse's ResilientMqttClient is the managed client — reconnect, re-subscription, and the offline queue are always on — with the raw layer (RawMqttClient) available when you want it.

Mental model ​

MQTTnetPulse
MqttFactory / MqttClientFactoryAddPulseMqttClient(...) (DI) or new ResilientMqttClient(...)
IMqttClient (manual reconnect)RawMqttClient (one connection, no reconnect)
IManagedMqttClient (auto reconnect + queue)ResilientMqttClient (the default)
MqttClientOptionsBuilderResilientMqttClientOptions / PulseMqttClientOptions
ApplicationMessageReceivedAsync eventMessages channel or the topic router
MqttApplicationMessageBuilderMqttPublishPacket or the fluent Publish(...)
MqttTopicFilterBuilderMqttTopicFilter

Creating and connecting a client ​

MQTTnet — build options, create the client, wire a reconnect loop yourself:

csharp
var factory = new MqttFactory();
var client = factory.CreateMqttClient();
var options = new MqttClientOptionsBuilder()
    .WithTcpServer("broker.example.com", 1883)
    .WithClientId("my-service")
    .WithCredentials("user", "pass")
    .WithCleanSession(false)
    .Build();
await client.ConnectAsync(options, ct);
// + a DisconnectedAsync handler that retries...

Pulse — register it; it connects in the background and reconnects on its own:

csharp
builder.Services.AddPulseMqttClient("devices", options =>
{
    options.Host = "broker.example.com";
    options.Port = 1883;
    options.ClientId = "my-service";
    options.Username = "user";
    options.Password = "pass";
    options.CleanStart = false;
});

// anywhere:
var client = provider.GetRequiredService<IPulseMqttClientFactory>().GetClient("devices");

No host? new ResilientMqttClient(new TcpTransportFactory(new TcpTransportOptions { Host = "..." }), options) then await client.ConnectAsync(ct). There is no reconnect loop or disconnect handler to write — watch State, or call WaitUntilConnectedAsync(...), if you want readiness.

Publishing ​

MQTTnet:

csharp
var message = new MqttApplicationMessageBuilder()
    .WithTopic("sensors/boiler/temp")
    .WithPayload(payloadBytes)
    .WithQualityOfServiceLevel(MqttQualityOfServiceLevelExactlyOnce)
    .WithRetainFlag()
    .Build();
await client.PublishAsync(message, ct);

Pulse — a record, or the fluent builder:

csharp
await client.PublishAsync(
    new MqttPublishPacket { Topic = "sensors/boiler/temp", Payload = payloadBytes, QualityOfService = MqttQualityOfService.ExactlyOnce, Retain = true },
    ct);

// or fluent / typed:
await client.Publish("sensors/boiler/temp").ExactlyOnce().WithRetain().WithPayload(reading).SendAsync(ct);

Pulse returns a PublishOutcome: Delivered, Queued (offline), DroppedOffline, or InFlight — publishing while disconnected queues instead of throwing, the same as MQTTnet's managed client EnqueueAsync, but you always learn which happened.

Subscribing and receiving ​

MQTTnet routes everything through one event and you branch on the topic:

csharp
client.ApplicationMessageReceivedAsync += async e =>
{
    if (e.ApplicationMessage.Topic.StartsWith("sensors/")) { /* ... */ }
};
await client.SubscribeAsync(new MqttTopicFilterBuilder().WithTopic("sensors/#").Build());

Pulse — subscribe, then either read the Messages channel or use the router with templates and captured parameters:

csharp
await client.SubscribeAsync([new MqttTopicFilter("sensors/+/temp") { MaximumQualityOfService = MqttQualityOfService.AtLeastOnce }], ct);

using IDisposable route = client.RegisterRoute<TelemetryReading>(
    MqttRouteTemplate.Parse("sensors/{deviceId}/temp"),
    (reading, message, token) => { Handle(message.Values["deviceId"], reading); return ValueTask.CompletedTask; });

Subscriptions are part of the durable session: Pulse re-applies them automatically after a reconnect — there is no "resubscribe on reconnect" handler to write.

Retained will and birth ​

MQTTnet sets the will on the options builder; the "birth" announcement you publish yourself after each connect:

csharp
new MqttClientOptionsBuilder()
    .WithWillTopic("clients/me/status").WithWillPayload("offline").WithWillRetain()

Pulse pairs a retained will with an automatic birth message published on every connection-up:

csharp
options.Will = new PulseMqttWillOptions { Topic = "clients/me/status", Payload = "offline", Retain = true };
options.Birth = new PulseMqttBirthOptions { Topic = "clients/me/status", Payload = "online", Retain = true };

The managed client ​

MQTTnet's IManagedMqttClient adds reconnect, a bounded message queue, and pluggable storage:

csharp
var managed = factory.CreateManagedMqttClient();
await managed.StartAsync(new ManagedMqttClientOptionsBuilder()
    .WithClientOptions(clientOptions)
    .WithMaxPendingMessages(1000)
    .WithStorage(myStorage)
    .Build());
await managed.EnqueueAsync(message);

In Pulse this is the baseline ResilientMqttClient. The queue bound and overflow policy live on OfflineQueue, and durable storage is available through the SQLite, SQL Server, or LiteDB storage packages:

csharp
options.OfflineQueue = new OfflineQueueOptions { Capacity = 1000, Overflow = OverflowPolicy.DropOldest };
// durable across restarts:
options.MessageStore = new SqliteMessageStore("queue.db", options.OfflineQueue);
options.SessionStore = new SqliteSessionStore("session.db");

EnqueueAsync has no separate counterpart — PublishAsync queues automatically when offline.

Request/response ​

MQTTnet has no built-in RPC; you correlate by hand with a response topic and a TaskCompletionSource. Pulse ships it — see Request and response:

csharp
StatusReply reply = await client.RequestAsync<StatusRequest, StatusReply>("devices/7/status", request, ct);

…including server-streamed responses via RequestStreamAsync.

TLS and WebSocket ​

MQTTnetPulse
.WithTls()options.UseTls = true (DI) / a TLS TcpTransportOptions
.WithWebSocketServer("wss://...")the Pulse.Mqtt.Transport.WebSocket package — see Connecting

What changes for the better ​

  • No reconnect plumbing. Drops, backoff, re-subscription, and offline flushing are built in and observable, not something you assemble from event handlers.
  • No silent loss. Every publish reports its disposition; the offline queue counts drops; nothing disappears quietly.
  • Channels over events. Received messages are a ChannelReader (or routed handlers), so backpressure and consumption are explicit rather than an async void-style event.
  • AOT-ready. Source-generated JSON/MessagePack and a reflection-free core make Native AOT a first-class target.

Released under the MIT License.