Presence
Device presence is two messages: a birth the client publishes the moment it is properly online, and a last will the broker publishes on the client's behalf when the connection dies ungracefully. Pulse makes both first-class, so the classic pattern needs zero application code:
status/device-7 ← birth publishes "online" (retained) on every connection-up
status/device-7 ← the broker publishes the will "offline" (retained) on ungraceful lossThe pattern in one block
await using var client = await new PulseMqttClientBuilder()
.WithTcp("broker.example.com")
.WithClientId("device-7")
.WithBirth("status/device-7", "online", MqttQualityOfService.AtLeastOnce, retain: true)
.WithWill("status/device-7", "offline", MqttQualityOfService.AtLeastOnce, retain: true)
.BuildAndConnectAsync(ct);Any subscriber of status/device-7 now sees online whenever the device connects (including every automatic reconnect), offline whenever it drops without a clean DISCONNECT, and — because both messages are retained — the current state immediately upon subscribing.
The last will
The will rides the CONNECT packet; the broker holds it and publishes it only on an ungraceful end (a clean DisconnectAsync/DISCONNECT withdraws it). Configure it three ways:
// Fluent: text, bytes, or typed payloads, plus the full v5 message form.
.WithWill("status/device-7", "offline", retain: true)
.WithWill(new MqttWillMessage("status/device-7") { Payload = bytes, DelayInterval = 30 })
.WithWill("status/device-7", new DeviceStatus("offline"), retain: true) // via the serializer
// Options (direct construction):
new ResilientMqttClientOptions { Will = new MqttWillMessage("status/device-7") { ... } }With dependency injection it binds from configuration:
{ "Mqtt": { "Devices": { "Will": {
"Topic": "status/device-7", "Payload": "offline",
"QualityOfService": "AtLeastOnce", "Retain": true, "DelaySeconds": 30 } } } }The v5 will delay (DelayInterval/DelaySeconds) is worth knowing: the broker waits that long before publishing the will, so a fast reconnect cancels the "offline" entirely — no flapping on brief network blips.
Dynamic will providers
A static will is frozen at startup. Use an IMqttWillProvider when the will belongs in a reusable service or needs connection context such as the client id, attempt counter, protocol version, clean-start flag, keep-alive, or timestamp:
public sealed class DeviceWillProvider : IMqttWillProvider
{
public ValueTask<MqttWillMessage?> CreateWillAsync(MqttWillContext context, CancellationToken ct)
{
return ValueTask.FromResult<MqttWillMessage?>(new MqttWillMessage($"status/{context.ClientId}")
{
Payload = JsonSerializer.SerializeToUtf8Bytes(new
{
state = "offline",
attempt = context.Attempt,
at = context.Timestamp,
}),
Retain = true,
});
}
}Register it fluently:
.WithWillProvider(new DeviceWillProvider())Or as a keyed dependency-injection service:
builder.Services
.AddPulseMqttClient("devices", options =>
{
options.Host = "broker.example.com";
options.ClientId = "device-7";
})
.UseWillProvider<DeviceWillProvider>();The provider runs before every CONNECT. Returning null connects without a will for that attempt. Throwing fails the attempt and lets the reconnect policy decide whether to retry. The provider wins over the legacy will factory, static Will, and any Connect.Will template.
The will factory — fresh per connection
A factory also runs on every connection attempt, before CONNECT is sent, but it only receives a cancellation token. It remains supported for small inline cases:
.WithWill(ct => ValueTask.FromResult(new MqttWillMessage("status/device-7")
{
Payload = JsonSerializer.SerializeToUtf8Bytes(new { state = "offline", at = DateTimeOffset.UtcNow }),
Retain = true,
}))A throwing factory fails that connection attempt like any connect failure — classified by the reconnect decision, never swallowed. With DI, register it via UseWillFactory(sp => token => ...). Prefer IMqttWillProvider for reusable components or when the will needs connection context.
MQTT 5-only will properties such as content type, payload format, message expiry, response topic, correlation data, user properties, and will delay still follow the configured protocol version. Use context.ProtocolVersion or the protocol feature guards when one provider supports both MQTT 5 and MQTT 3.1.1 clients.
The birth message
Published automatically on every connection-up, at a deliberate point in the sequence:
- The CONNECT/CONNACK handshake completes.
- Re-subscription restores the durable subscription set.
- The birth publishes.
- The offline queue flushes.
- The state becomes
Connected.
Nobody ever observes "online" from a client whose session is not actually restored yet, and the birth lands before any backlogged traffic.
.WithBirth("status/device-7", "online", MqttQualityOfService.AtLeastOnce, retain: true)
.WithBirth(new MqttPublishPacket { Topic = ..., UserProperties = [...] })
.WithBirth("status/device-7", new DeviceStatus("online"), retain: true) // typed, via the serializerThe birth factory mirrors the will factory and sees the connection attempt counter:
.WithBirth((attempt, ct) => ValueTask.FromResult(new MqttPublishPacket
{
Topic = "status/device-7",
Payload = JsonSerializer.SerializeToUtf8Bytes(new { state = "online", attempt }),
Retain = true,
}))DI: bindable static form on PulseMqttClientOptions.Birth, factory via UseBirthFactory.
When the birth fails
BirthFailurePolicy decides (an option, WithBirthFailurePolicy(...) fluently):
FailConnection(default) — the connection-up fails and the reconnect cycle retries. Presence stays truthful: nobody observes a connected client whose announcement never went out.LogAndContinue— the failure is logged (BirthPublishFailed, event 7) and counted (disposition="BirthFailed"on the published-messages counter); the connection proceeds.
Verified end to end
The integration suite proves the full cycle against a real Mosquitto with no application code: connect → retained online; socket killed without a DISCONNECT → the broker publishes the retained offline will; the automatic reconnect → online again.