A .NET MAUI MQTT app lets you ship one C# codebase that monitors and controls devices from Android, iOS, Windows and macOS. In this tutorial you will build a small MAUI app with MQTTnet that shows a live temperature, displays device presence and toggles a relay, and you will handle the part most samples skip: what happens when the app goes to the background. The examples use the public broker broker.connectmq.com, then switch to private ConnectMQ credentials.
What You Will Build
- A single page with a connection indicator, the latest temperature and a relay switch
- A view model that owns one MQTTnet client and exposes bindable properties
- Commands published to
.../relay/setand state read from.../relay/state - Reconnect on resume, with resubscribe
Topics, all under a unique prefix so you do not collide with other users of the shared broker:
| Topic | Direction | Payload |
|---|---|---|
connectmq/demo/m4ui7x/device1/temperature |
device to app | {"value": 22.4} |
connectmq/demo/m4ui7x/device1/status |
device to app (retained) | online / offline |
connectmq/demo/m4ui7x/device1/relay/state |
device to app (retained) | on / off |
connectmq/demo/m4ui7x/device1/relay/set |
app to device | on / off |
Splitting set from state is a deliberate pattern: the app requests a change, the device confirms the real state. The switch on screen reflects what the device reports, not what the user tapped.
Create the Project
You need the .NET 8 or 9 SDK with the MAUI workload (dotnet workload install maui).
dotnet new maui -n DeviceControl
cd DeviceControl
dotnet add package MQTTnet
dotnet add package CommunityToolkit.Mvvm
CommunityToolkit.Mvvm removes most of the property-changed boilerplate. It is optional but makes the view model much shorter.
The View Model
Create MainViewModel.cs. The critical rule in any .NET MAUI MQTT app: MQTTnet raises events on background threads, and UI-bound properties must be updated on the main thread. Forgetting this produces crashes on Android and iOS that are confusing because they do not happen on Windows every time.
using System.Text.Json;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MQTTnet;
using MQTTnet.Formatter;
using MQTTnet.Protocol;
namespace DeviceControl;
public partial class MainViewModel : ObservableObject
{
private const string Prefix = "connectmq/demo/m4ui7x/device1";
private readonly IMqttClient _client = new MqttClientFactory().CreateMqttClient();
private readonly MqttClientOptions _options;
private bool _suppressToggle;
[ObservableProperty] private string connectionText = "Disconnected";
[ObservableProperty] private string temperatureText = "--";
[ObservableProperty] private string deviceStatus = "unknown";
[ObservableProperty] private bool relayOn;
public MainViewModel()
{
_options = new MqttClientOptionsBuilder()
.WithTcpServer("broker.connectmq.com", 8883)
.WithTlsOptions(o => o.UseTls())
.WithClientId(GetOrCreateClientId())
.WithProtocolVersion(MqttProtocolVersion.V500)
.WithKeepAlivePeriod(TimeSpan.FromSeconds(30))
.Build();
_client.ApplicationMessageReceivedAsync += e =>
{
var topic = e.ApplicationMessage.Topic;
var payload = e.ApplicationMessage.ConvertPayloadToString();
MainThread.BeginInvokeOnMainThread(() => Apply(topic, payload));
return Task.CompletedTask;
};
_client.DisconnectedAsync += _ =>
{
MainThread.BeginInvokeOnMainThread(() => ConnectionText = "Disconnected");
return Task.CompletedTask;
};
}
private static string GetOrCreateClientId()
{
var id = Preferences.Get("mqtt_client_id", null as string);
if (id is null)
{
id = $"maui-{Guid.NewGuid():N}"[..20];
Preferences.Set("mqtt_client_id", id);
}
return id;
}
private void Apply(string topic, string payload)
{
if (topic.EndsWith("/temperature"))
{
try
{
using var doc = JsonDocument.Parse(payload);
TemperatureText = $"{doc.RootElement.GetProperty("value").GetDouble():0.0} °C";
}
catch (Exception) { TemperatureText = "invalid"; }
}
else if (topic.EndsWith("/status"))
{
DeviceStatus = payload;
}
else if (topic.EndsWith("/relay/state"))
{
_suppressToggle = true;
RelayOn = payload == "on";
_suppressToggle = false;
}
}
[RelayCommand]
public async Task ConnectAsync()
{
if (_client.IsConnected) return;
try
{
ConnectionText = "Connecting...";
await _client.ConnectAsync(_options);
await _client.SubscribeAsync(new MqttClientSubscribeOptionsBuilder()
.WithTopicFilter(f => f.WithTopic($"{Prefix}/temperature"))
.WithTopicFilter(f => f.WithTopic($"{Prefix}/status"))
.WithTopicFilter(f => f.WithTopic($"{Prefix}/relay/state")
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce))
.Build());
ConnectionText = "Connected";
}
catch (Exception ex)
{
ConnectionText = $"Error: {ex.Message}";
}
}
public async Task DisconnectAsync()
{
if (_client.IsConnected) await _client.DisconnectAsync();
}
partial void OnRelayOnChanged(bool value)
{
if (_suppressToggle || !_client.IsConnected) return;
_ = _client.PublishAsync(new MqttApplicationMessageBuilder()
.WithTopic($"{Prefix}/relay/set")
.WithPayload(value ? "on" : "off")
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
.Build());
}
}
The _suppressToggle flag prevents an echo loop: when the device reports a new state, setting RelayOn would otherwise publish a command again. The client ID is generated once and stored in Preferences, so it stays stable across launches, which matters if you later enable persistent sessions.
The Page
Replace the content of MainPage.xaml:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:DeviceControl"
x:Class="DeviceControl.MainPage"
x:DataType="local:MainViewModel"
Title="Device Control">
<VerticalStackLayout Padding="24" Spacing="16">
<Label Text="{Binding ConnectionText}" FontSize="14" />
<Label Text="Temperature" FontSize="16" />
<Label Text="{Binding TemperatureText}" FontSize="48" FontAttributes="Bold" />
<Label Text="{Binding DeviceStatus, StringFormat='Device: {0}'}" />
<HorizontalStackLayout Spacing="12">
<Label Text="Relay" VerticalOptions="Center" />
<Switch IsToggled="{Binding RelayOn}" />
</HorizontalStackLayout>
<Button Text="Reconnect" Command="{Binding ConnectCommand}" />
</VerticalStackLayout>
</ContentPage>
And MainPage.xaml.cs:
namespace DeviceControl;
public partial class MainPage : ContentPage
{
private readonly MainViewModel _vm;
public MainPage(MainViewModel vm)
{
InitializeComponent();
BindingContext = _vm = vm;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await _vm.ConnectAsync();
}
}
Register both in MauiProgram.cs with builder.Services.AddSingleton<MainViewModel>(); and builder.Services.AddSingleton<MainPage>();, and make sure AppShell resolves the page from DI (the default template's ShellContent with ContentTemplate works once the page is registered).
Handling the App Lifecycle
Mobile operating systems suspend background apps and close their sockets. Android may keep the process alive for a while but stop the network; iOS suspends almost immediately. Trying to keep an MQTT connection open in the background without a foreground service (Android) or a push notification strategy (iOS) is a losing fight. The practical approach for a monitoring app:
- Disconnect cleanly when the app goes to the background.
- Reconnect and resubscribe when it resumes.
- Rely on retained messages for state, so the screen is correct the instant you reconnect.
Wire it in App.xaml.cs by overriding CreateWindow and hooking window events:
protected override Window CreateWindow(IActivationState? activationState)
{
var window = new Window(new AppShell());
var vm = activationState!.Context.Services.GetRequiredService<MainViewModel>();
window.Stopped += async (_, _) => await vm.DisconnectAsync();
window.Resumed += async (_, _) => await vm.ConnectAsync();
return window;
}
Because the device publishes status and relay/state as retained messages, the broker delivers them immediately on subscribe. The app never shows a stale switch after waking up. For alerts while the app is closed, a backend service subscribing on the server side and sending push notifications is the right architecture; see the ASP.NET Core MQTT background service for that part.
Simulate the Device
You do not need hardware to test. From a terminal, play the role of the device:
# Retained presence and relay state
mosquitto_pub -h broker.connectmq.com -p 1883 -i dev1-sim-a -r -t connectmq/demo/m4ui7x/device1/status -m online
mosquitto_pub -h broker.connectmq.com -p 1883 -i dev1-sim-b -r -t connectmq/demo/m4ui7x/device1/relay/state -m off
# Temperature reading
mosquitto_pub -h broker.connectmq.com -p 1883 -i dev1-sim-c -t connectmq/demo/m4ui7x/device1/temperature -m '{"value": 22.4}'
# Watch the commands the app sends
mosquitto_sub -h broker.connectmq.com -p 1883 -i dev1-sim-d -t connectmq/demo/m4ui7x/device1/relay/set -v
Tap the switch in the app and the command appears in the last terminal. Then publish on to relay/state as the device would, and the switch confirms. Real firmware follows the same contract; the ESP32 MQTT tutorial shows a device side you can pair with this app.
Showing Feedback While a Command Is Pending
Because the switch reflects the device's confirmed state, there is a short gap between the tap and the confirmation. Users read that gap as "the app is broken" unless you show it. A simple approach is a Pending property that turns on when the command is published and off when relay/state arrives, bound to an ActivityIndicator. Add a timeout of a few seconds: if no confirmation arrives, show "Device did not respond" and leave the switch at the last confirmed state. This small detail makes a .NET MAUI MQTT app feel trustworthy, and it surfaces real problems such as an offline device or a firmware bug instead of hiding them.
If you also want to know whether the device is reachable before the user taps, rely on the retained status topic driven by the device's Last Will. Disable the switch while status is offline.
Platform Pitfalls
| Platform | Pitfall | Fix |
|---|---|---|
| Android | SocketException or connection refused on emulator |
Ensure the emulator has internet; the INTERNET permission is in the MAUI template by default |
| Android | Crash when updating labels from the message handler | Always marshal to MainThread |
| iOS | Connection drops seconds after backgrounding | Expected; disconnect on Stopped, reconnect on Resumed |
| Windows | Works on Windows, crashes on mobile | Usually a threading bug hidden by the Windows dispatcher; test on a phone early |
| All | Plain TCP 1883 blocked on mobile networks or guest Wi-Fi | Prefer TLS on 8883, or WebSocket over 8084 with WithWebSocketServer |
| All | Two app instances with the same ID kick each other | Store a per-install client ID, as shown above |
Production Credentials and Security
Shipping a mobile app means the binary can be decompiled, so never embed a shared admin password. Options, from simplest to most robust:
- Issue one credential per user or per installation, provisioned by your backend after login.
- Keep commands behind your own API: the app calls your backend, and the backend publishes to the broker.
- Store any credential the app does hold in
SecureStorage, notPreferences.
With ConnectMQ, each broker comes with a generated username, password and client ID, and client IDs are bound to their credentials. Plug them into the options builder with .WithCredentials("YOUR_USERNAME", "YOUR_PASSWORD") and .WithClientId("YOUR_CLIENT_ID") and point WithTcpServer at your broker host. The MQTT security best practices article covers credential strategy in more depth, and the MQTTnet tutorial explains the client options used here.
Testing on the free public MQTT broker is fine while you build the UI. Production apps, where real users control real equipment, need a private hosted MQTT broker with authentication and TLS. For a wider view of mobile and desktop options beyond .NET, see the MQTT client libraries comparison.
Get Started with ConnectMQ
Create your private broker at app.connectmq.com on the free Serverless plan, with no credit card. It supports up to 25 connections, enough for a development team and a handful of test devices, and the portal's in-browser MQTT client lets you play the device role without a terminal.
Frequently Asked Questions
Can a .NET MAUI app receive MQTT messages in the background?
Not reliably on its own. Mobile operating systems suspend background networking, so use retained messages for state on resume, and a server-side subscriber with push notifications for alerts while the app is closed.
Does MQTTnet work on iOS and Android with MAUI?
Yes. MQTTnet is a pure .NET library and runs on all MAUI targets. Use TLS on port 8883 and make sure UI updates happen on the main thread.
Should the mobile app connect directly to the broker or go through an API?
For monitoring, a direct connection with read-only credentials is efficient. For control of real equipment, many teams route commands through their own backend so they can apply authorization and audit logging before publishing.
How do I migrate a Xamarin.Forms MQTT app to MAUI?
The MQTT code usually moves over unchanged if you used MQTTnet. Update to the current MQTTnet API (MqttClientFactory in version 5), replace Xamarin.Essentials calls with MAUI equivalents, and retest threading on each platform.



