This iOS MQTT Swift tutorial builds a small SwiftUI app that subscribes to live sensor topics and publishes commands to a device. You will add CocoaMQTT with Swift Package Manager, wrap it in an observable model, connect over TLS to broker.connectmq.com, and handle the reconnects that real iPhones need.
What You Need
- Xcode 15 or newer and an iOS 16+ simulator or device.
- The CocoaMQTT package (MQTT 3.1.1 and MQTT 5 support, open source, maintained by EMQX).
- A broker. For learning, the public MQTT test broker at broker.connectmq.com accepts anonymous clients with any non-empty client ID. Use a random topic prefix like
connectmq/demo/<random-id>/..., because the public area is readable by everyone. - Optionally, the Mosquitto CLI tools for simulating a device.
The app uses two topics:
| Topic | Direction | Payload |
|---|---|---|
<prefix>/sensors/+/temperature |
device to app | 21.7 |
<prefix>/fan/set |
app to device | ON / OFF |
The + wildcard means one subscription covers every sensor (kitchen, office, ...), and the app builds its list dynamically. If your team also ships an Android or cross-platform build, the same topics work unchanged with the Android MQTT client in Kotlin or a Flutter MQTT app.
Adding CocoaMQTT to the Project
In Xcode choose File, Add Package Dependencies, and enter:
https://github.com/emqx/CocoaMQTT.git
Select "Up to Next Major Version" from 2.1.0 and add the CocoaMQTT library to your app target. CocoaMQTT pulls in its socket dependency automatically. The project's README on GitHub is the official CocoaMQTT documentation and lists the full delegate API.
No App Transport Security exception is needed: you are connecting over TLS to a host with a publicly trusted Let's Encrypt certificate, so iOS validates it against the system trust store.
Building the iOS MQTT Swift Model
Keep networking out of SwiftUI views. An ObservableObject owns the client, publishes connection state and readings, and exposes a setFan method.
MQTTModel.swift:
import Foundation
import CocoaMQTT
@MainActor
final class MQTTModel: ObservableObject {
@Published var isConnected = false
@Published var readings: [String: Double] = [:]
@Published var lastError: String?
let topicPrefix: String
private let mqtt: CocoaMQTT
init(host: String = "broker.connectmq.com",
port: UInt16 = 8883,
username: String? = nil,
password: String? = nil,
clientID: String? = nil,
topicPrefix: String? = nil) {
let suffix = String(UUID().uuidString.prefix(8)).lowercased()
self.topicPrefix = topicPrefix ?? "connectmq/demo/\(suffix)"
let id = clientID ?? "ios-\(suffix)"
mqtt = CocoaMQTT(clientID: id, host: host, port: port)
mqtt.username = username
mqtt.password = password
mqtt.enableSSL = true
mqtt.keepAlive = 30
mqtt.cleanSession = true
mqtt.autoReconnect = true
mqtt.autoReconnectTimeInterval = 2
mqtt.maxAutoReconnectTimeInterval = 60
mqtt.didConnectAck = { [weak self] client, ack in
Task { @MainActor in
guard let self else { return }
self.isConnected = (ack == .accept)
if ack == .accept {
client.subscribe("\(self.topicPrefix)/sensors/+/temperature", qos: .qos1)
} else {
self.lastError = "Connection rejected: \(ack)"
}
}
}
mqtt.didReceiveMessage = { [weak self] _, message, _ in
Task { @MainActor in
guard let self,
let text = message.string,
let value = Double(text.trimmingCharacters(in: .whitespaces)) else { return }
// topic: <prefix>/sensors/<name>/temperature
let parts = message.topic.split(separator: "/")
guard parts.count >= 2 else { return }
let name = String(parts[parts.count - 2])
self.readings[name] = value
}
}
mqtt.didDisconnect = { [weak self] _, error in
Task { @MainActor in
self?.isConnected = false
if let error { self?.lastError = error.localizedDescription }
}
}
}
func connect() {
lastError = nil
if !mqtt.connect() {
lastError = "Could not start connection"
}
}
func disconnect() {
mqtt.disconnect()
}
func setFan(_ on: Bool) {
mqtt.publish("\(topicPrefix)/fan/set", withString: on ? "ON" : "OFF", qos: .qos1)
}
}
Three choices are worth understanding:
- Callbacks hop to the main actor. CocoaMQTT fires its closures on its own queue. Updating
@Publishedproperties from a background thread produces SwiftUI warnings and occasional glitches, so every callback wraps its work inTask { @MainActor in ... }. - Subscribing in
didConnectAck. WithcleanSession = true, the broker forgets subscriptions on disconnect. Subscribing inside the connect acknowledgement means every automatic reconnect restores them. - Defensive parsing. Anyone can publish to a public topic, so a non-numeric payload is ignored instead of crashing the app.
The SwiftUI Screen
ContentView.swift:
import SwiftUI
struct ContentView: View {
@StateObject private var model = MQTTModel()
@State private var fanOn = false
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack {
List {
Section("Connection") {
Label(model.isConnected ? "Connected" : "Disconnected",
systemImage: model.isConnected ? "checkmark.circle" : "xmark.circle")
if let err = model.lastError {
Text(err).foregroundStyle(.red).font(.footnote)
}
Text(model.topicPrefix).font(.caption).textSelection(.enabled)
}
Section("Sensors") {
if model.readings.isEmpty {
Text("Waiting for data...").foregroundStyle(.secondary)
}
ForEach(model.readings.keys.sorted(), id: \.self) { name in
HStack {
Text(name.capitalized)
Spacer()
Text(String(format: "%.1f °C", model.readings[name] ?? 0))
.monospacedDigit()
}
}
}
Section("Commands") {
Toggle("Fan", isOn: $fanOn)
.onChange(of: fanOn) { _, newValue in model.setFan(newValue) }
.disabled(!model.isConnected)
}
}
.navigationTitle("iOS MQTT Demo")
}
.onAppear { model.connect() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active: model.connect()
case .background: model.disconnect()
default: break
}
}
}
}
The scenePhase handler is the important iOS-specific part. When your app goes to the background, iOS suspends it within seconds and the socket dies silently. Disconnecting cleanly on .background and reconnecting on .active gives users a fresh, subscribed connection the moment they reopen the app. The onChange two-parameter form shown here requires iOS 17; on iOS 16 use the single-parameter closure.
Testing the App With a Simulated Sensor
Run the app, copy the topic prefix from the screen, and publish readings from a terminal:
PREFIX="connectmq/demo/1a2b3c4d" # paste the prefix shown in the app
mosquitto_pub -h broker.connectmq.com -p 1883 -i ios-sim-kitchen \
-t "$PREFIX/sensors/kitchen/temperature" -m "22.8"
mosquitto_pub -h broker.connectmq.com -p 1883 -i ios-sim-office \
-t "$PREFIX/sensors/office/temperature" -m "20.1"
# Watch fan commands from the app
mosquitto_sub -h broker.connectmq.com -p 1883 -i ios-sim-fan -t "$PREFIX/fan/set" -v
Both sensors should appear in the list, and toggling the fan prints ON or OFF in the subscriber. If you would rather click than type, the ConnectMQ Desktop MQTT explorer and the in-browser client in the portal do the same job.
Showing Device Online Status
A list of temperatures is only trustworthy if the user knows the sensors are alive. MQTT has a built-in answer. Each device connects with a last will message on a status topic, for example <prefix>/sensors/kitchen/status with the payload offline and the retain flag set, and publishes a retained online right after connecting. If the device loses power or network, the broker publishes the will on its behalf. The details are in MQTT last will and testament.
On the app side, add a second subscription in didConnectAck for "\(topicPrefix)/sensors/+/status", store the values in a [String: Bool] dictionary, and grey out rows whose sensor is offline. Because both messages are retained, the app learns the current status of every sensor the moment it connects, without waiting for the next reading.
The same idea applies to the app itself. If other systems need to know whether an operator's phone is connected, set a will in the model's initializer, before connect() is called:
mqtt.willMessage = CocoaMQTTMessage(topic: "\(self.topicPrefix)/apps/\(id)/status",
string: "offline", qos: .qos1, retained: true)
For most consumer apps this is unnecessary, but for operator and field-service apps it gives supervisors a live view of who is connected.
Using WebSocket Instead of TLS
Some corporate and hotel Wi-Fi networks block port 8883. CocoaMQTT can tunnel MQTT through secure WebSocket on port 8084, which looks like ordinary HTTPS traffic:
let websocket = CocoaMQTTWebSocket(uri: "/mqtt")
let client = CocoaMQTT(clientID: id, host: "broker.connectmq.com", port: 8084, socket: websocket)
client.enableSSL = true
Everything else in the model stays the same. The trade-offs are covered in MQTT over WebSocket.
Going to Production on a Private Broker
When the prototype works, move off the public broker to your own hosted MQTT broker:
- Create a broker in the ConnectMQ portal and copy the generated username, password and client ID.
- Initialise the model with them:
MQTTModel(username: "YOUR_USERNAME", password: "YOUR_PASSWORD", clientID: "YOUR_CLIENT_ID", topicPrefix: "acme/home1"). - Store the password in the Keychain, not in
UserDefaultsor source code. - Keep
enableSSL = trueon port 8883.
Because ConnectMQ binds client IDs to issued credentials, avoid hard-coding one shared login into every installed copy of an App Store app. A backend that issues per-user or per-device credentials is the safer design: the app signs the user in, receives its own MQTT username, password and client ID over HTTPS, stores them in the Keychain, and your backend can revoke a single user's access without shipping an app update to everyone else. The MQTT security best practices guide goes deeper on credential handling.
Troubleshooting Checklist
| Problem | Check |
|---|---|
didConnectAck never fires |
Is enableSSL set for port 8883, and off for 1883? A TLS/plain mismatch hangs the handshake. |
Ack is .identifierRejected |
Client ID is empty or already in use by another session. |
Ack is .badUsernameOrPassword |
Credentials copied with trailing whitespace, or wrong broker. |
| Works on simulator, not on device | Device is on a network that blocks 8883; try WebSocket on 8084. |
| Values stop after unlocking the phone | Missing scenePhase reconnect, or subscriptions not restored in didConnectAck. |
| SwiftUI purple runtime warnings | @Published property updated off the main actor. |
Get Started with ConnectMQ
Your SwiftUI app is one credential swap away from production. Create a free Serverless broker at app.connectmq.com with no credit card, paste the username, password and client ID into MQTTModel, and use the portal's live topic explorer to watch your iPhone and devices talk to each other. ConnectMQ runs the broker so you can focus on the app.
Frequently Asked Questions
Which Swift MQTT library should I use on iOS?
CocoaMQTT is the most widely used choice, with Swift Package Manager support, TLS, WebSocket and both MQTT 3.1.1 and MQTT 5 clients. For a broader comparison across platforms, see the MQTT client libraries overview.
Can an iOS app receive MQTT messages in the background?
Not reliably. iOS suspends background apps and their sockets, so reconnect when the app becomes active and use push notifications for alerts that must reach a locked phone.
Do I need an App Transport Security exception for MQTT?
No, when you connect with TLS to a broker that uses a publicly trusted certificate, as broker.connectmq.com does on ports 8883 and 8084. ATS rules apply to URL loading, and plain-TCP MQTT on 1883 should be avoided in shipped apps anyway.
How do I use MQTT 5 with CocoaMQTT?
Use the CocoaMQTT5 class instead of CocoaMQTT. Its API is similar and adds MQTT 5 properties; ConnectMQ supports both protocol versions.



