To use MQTT in an Android app with Kotlin, add a maintained Java MQTT client — the HiveMQ MQTT Client is the best fit today — wrap it in a repository that exposes connection state and messages as Kotlin flows, and tie the connection to your app's lifecycle instead of trying to keep it alive forever. This tutorial builds exactly that, explains what Android does to long-lived sockets in the background, and shows how to test the result against a real broker.
Choosing a client library
| Library | Status on Android | Notes |
|---|---|---|
| HiveMQ MQTT Client | Works, actively maintained | MQTT 3.1.1 and 5, async API returns CompletableFuture — easy to bridge to coroutines |
Eclipse Paho Java (mqttv3) | Works | Mature MQTT 3.1.1 client with callbacks; you handle threading yourself |
| Eclipse Paho Android Service | Avoid | Unmaintained, breaks on recent Android versions |
The examples below use the HiveMQ client. The same architecture works with Paho Java — only the connect and subscribe calls differ. If you know the client from the JVM, MQTT in Java with the HiveMQ MQTT Client covers its API in depth.
Project setup
android {
defaultConfig {
minSdk = 24
}
}
dependencies {
implementation("com.hivemq:hivemq-mqtt-client:1.3.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
}<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />The client relies on Java 8 APIs such as CompletableFuture, which are available from API 24. If R8 reports missing classes from the client's Netty dependency during a release build, add the keep rules from the client's Android documentation.
A repository with coroutines and flows
Keep exactly one client per app, owned by a long-lived object — not by an Activity, which is recreated on every rotation. The repository below exposes connection state as a StateFlow and incoming messages as a SharedFlow:
import com.hivemq.client.mqtt.MqttClient
import com.hivemq.client.mqtt.datatypes.MqttQos
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.future.await
data class MqttMessage(val topic: String, val payload: String)
sealed interface ConnState {
data object Disconnected : ConnState
data object Connected : ConnState
data class Failed(val reason: String) : ConnState
}
class MqttRepository(clientId: String) {
private val _state = MutableStateFlow<ConnState>(ConnState.Disconnected)
val state: StateFlow<ConnState> = _state
private val _messages = MutableSharedFlow<MqttMessage>(extraBufferCapacity = 64)
val messages: SharedFlow<MqttMessage> = _messages
private val client: Mqtt5AsyncClient = MqttClient.builder()
.useMqttVersion5()
.identifier(clientId)
.serverHost("broker.connectmq.com")
.serverPort(8883)
.sslWithDefaultConfig()
.automaticReconnectWithDefaultConfig()
.addConnectedListener { _state.value = ConnState.Connected }
.addDisconnectedListener { _state.value = ConnState.Failed(it.cause.message ?: "disconnected") }
.buildAsync()
suspend fun connect(user: String, pass: String) {
client.connectWith()
.simpleAuth().username(user).password(pass.toByteArray()).applySimpleAuth()
.keepAlive(60)
.send()
.await()
}
suspend fun subscribe(filter: String) {
client.subscribeWith()
.topicFilter(filter)
.qos(MqttQos.AT_LEAST_ONCE)
.callback { p ->
_messages.tryEmit(MqttMessage(p.topic.toString(), String(p.payloadAsBytes)))
}
.send()
.await()
}
suspend fun publish(topic: String, payload: String) {
client.publishWith()
.topic(topic)
.qos(MqttQos.AT_LEAST_ONCE)
.payload(payload.toByteArray())
.send()
.await()
}
suspend fun disconnect() {
client.disconnect().await()
_state.value = ConnState.Disconnected
}
}await() comes from kotlinx.coroutines.future, which is part of the core coroutines library, and turns every CompletableFuture into a suspending call. The subscription callback runs on the client's own thread; tryEmit hands the message over without blocking it. A ViewModel then simply collects repository.messages and repository.state in viewModelScope.
Tie the connection to the app lifecycle
The simplest reliable pattern for most apps: connect when the app comes to the foreground, disconnect when it goes to the background. ProcessLifecycleOwner reports this for the whole process, not per Activity, so rotations and screen changes do not cause reconnects:
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
class App : android.app.Application() {
lateinit var mqtt: MqttRepository
override fun onCreate() {
super.onCreate()
mqtt = MqttRepository(clientId = InstallId.get(this))
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
owner.lifecycleScope.launch {
runCatching {
mqtt.connect(BuildConfig.MQTT_USER, BuildConfig.MQTT_PASS)
mqtt.subscribe("testmqtt/android/demo/#")
}
}
}
override fun onStop(owner: LifecycleOwner) {
owner.lifecycleScope.launch { runCatching { mqtt.disconnect() } }
}
})
}
}InstallId stands for your own helper that returns the stored per-install UUID. Do not ship real broker credentials in BuildConfig for a production app — give each user or device its own credentials from your backend. For quick tests, placeholders are fine.
What Android does in the background
A TCP connection in a backgrounded app does not survive for long, and that is by design:
- Doze and App Standby suspend network access for idle devices and rarely used apps. Your keep-alive PINGREQs stop, and the broker closes the connection after 1.5 × the keep-alive interval.
- Cached processes can be frozen or killed at any time to free memory; the socket dies with them.
- OEM battery savers on many devices are stricter than stock Android and may kill background work outright.
- Foreground services keep the process running, but require a visible notification and a declared service type on Android 14+, and Android 15 limits how long
dataSyncforeground services may run.
The practical consequences: do not promise users real-time MQTT delivery while the app is closed. If something important must reach a closed app — an alarm, a chat message — send a push notification via Firebase Cloud Messaging from your backend and let it open the app, which then reconnects and syncs. Use a foreground service only when the user is actively doing something ongoing, such as live tracking during a trip. Android background disconnects goes deeper into diagnosing each case.
Persistent sessions: catching up after a resume
When the app returns to the foreground, it can receive the QoS 1 messages it missed — if the broker kept its session. Set cleanStart(false) and a sessionExpiryInterval in connectWith(), and keep the client ID stable. The broker then queues messages for the subscriptions while the app is away, up to the expiry time. Choose an interval that matches how long missed data stays useful; a day of queued sensor readings arriving at once is rarely what the UI wants. The shared test endpoint does not keep offline sessions, so try this with your own private broker instance. Read MQTT persistent sessions for the exact rules.
Network changes and keep alive
Phones switch between Wi-Fi and mobile data constantly, and each switch silently kills existing TCP connections. The client's automatic reconnect handles that, but detection is only as fast as your keep alive: with 60 seconds, a dead connection may go unnoticed for up to 90 seconds. To react faster, register a ConnectivityManager.NetworkCallback and trigger a reconnect from onAvailable. Keep the interval between 30 and 120 seconds on mobile — shorter wastes battery, longer delays detection. MQTT keep alive explains the timing.
Test it on a device
- Run the app on a device or emulator and watch
stateturnConnected. - Open the online MQTT client on your computer, connect to the same broker and publish to
testmqtt/android/demo/hello. The message should appear in the app. - Publish from the app and subscribe in the browser to
testmqtt/android/#to confirm the other direction. - Put the app in the background, wait longer than the keep-alive timeout, and bring it back. You should see a fresh
Connectedstate and, with a persistent session, the messages you published in the meantime. - Toggle airplane mode for a few seconds and confirm the client reconnects by itself.
Watch your Android app's messages
Connecting to a broker on your own computer from the emulator? Use 10.0.2.2 instead of localhost. For more device-side checks, see testing an MQTT broker from Android.
MQTT on Android works best when you accept the platform's rules: one client in a long-lived repository, a connection that follows the app lifecycle, persistent sessions to catch up, push notifications for anything urgent while the app is closed, and TLS everywhere. Build that once and every screen of your app simply collects a flow.
Frequently asked questions
Should I still use the Eclipse Paho Android Service?
Not for new apps. The org.eclipse.paho.android.service library has not been maintained for years and breaks on recent Android versions (for example PendingIntent mutability requirements since Android 12). Use the HiveMQ MQTT Client or the plain Paho Java client inside your own app architecture instead.
Can an Android app keep an MQTT connection open in the background?
Only with a foreground service and a visible notification, and even then Doze, OEM battery savers and newer foreground-service rules can interrupt it. For most apps, connect while the app is visible and use push notifications (FCM) to wake the app for important events.
Why does my MQTT connection work on Wi-Fi but not on mobile data?
Some mobile networks and corporate Wi-Fi block non-HTTP ports such as 1883 and 8883. Connect over secure WebSockets on port 443 or 8084 to test whether the port is the problem, and always use TLS on networks you do not control.


