
Kotlin cross-platform, coroutine based, reflectionless MQTT 3.1.1 & 5.0 client
Coroutines-based Kotlin Multiplatform MQTT 3.1.1 (v4) & 5.0 client. Buffer-backed, zero-copy, backed by 5000+ tests.
This project simplifies managing an MQTT client across multiple platforms with a single Kotlin API.
| Concern | Without | With com.ditchoom:mqtt-client
|
|---|---|---|
| Platform I/O | Separate MQTT stacks per platform | One coroutines API on JVM, Android, iOS/macOS/tvOS/watchOS, JS (browser + Node), Linux |
| Protocol | Pick a v3.1.1 or v5 library | Both MQTT 3.1.1 (v4) and 5.0 behind one client |
| Reliability | Hand-roll reconnect + QoS state | Automatic reconnection, QoS 0/1/2 state machines, message persistence, offline buffering |
| Payloads |
ByteArray copies everywhere |
Zero-copy ReadBuffer + typed Codec<P> payloads |
| Transport | Rewrite for TCP vs WebSocket | Composable transport seam (TCP today; WebSocket, QUIC, WebTransport on the same seam) |
| High availability | Manual failover | A broker can list multiple connection options and fail over between them |
@ProtocolMessage) — no hand-written wire layer — allowing customization or custom protocols derived from MQTT without a full rewrite.ByteArray copies on the hot path.Buffer uses native buffers to pass to the socket:
| Platform | Native Buffer Type |
|---|---|
| Android / JVM | ByteBuffer |
| iOS/macOS/tvOS/watchOS | NSData |
| BrowserJS/NodeJS | ArrayBuffer / SharedArrayBuffer |
| Linux x64/arm64 | NativeBuffer |
Socket uses native socket APIs:
| Platform | Native Socket Impl |
|---|---|
| Android/JVM | AsynchronousSocketChannel (fallback SocketChannel) |
| iOS/macOS/tvOS/watchOS | NWConnection |
| NodeJS | Net module |
| BrowserJS | unavailable (use WebSocket / WebTransport) |
| Linux x64/arm64 | io_uring / epoll |
Persistence uses:
| Platform | Persistence Impl |
|---|---|
| Android/JVM | SQLite via SQLDelight |
| iOS/macOS/tvOS/watchOS | SQLite via SQLDelight using -lsqlite3
|
| NodeJS | In-memory |
| BrowserJS | IndexedDB (SQLite-wasm upcoming) |
| Linux x64/arm64 | SQLite via SQLDelight using -lsqlite3
|
An in-memory Persistence is available on every platform.
DitchOoM Kotlin Multiplatform runtime dependencies:
Codec framework, and the buffer-flow byte layer.Official Kotlin Multiplatform runtime dependencies:
Other runtime dependencies:
| Platform | MQTT 3.1.1 (v4) | MQTT 5.0 | LWT | SSL / TLS | Persistence | Auto Reconnect | Offline Buffering | TCP | WebSocket | Coroutines API | High Availability |
|---|---|---|---|---|---|---|---|---|---|---|---|
JVM |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
Browser |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | ⛔ | 🚀 | 🚀 | 🚀 |
Node.JS |
🚀 | 🚀 | 🚀 | 🚀 | 📝 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
Android |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
iOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
macOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
watchOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
tvOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
Linux |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
🚀 = Ready 📝 = Planned ⛔ = Unavailable (platform API limits)
Published to Maven Central under com.ditchoom.
dependencies {
// The client (pulls in buffer + socket transitively).
implementation("com.ditchoom:mqtt-client:<version>")
// Add the protocol version(s) you need — or both:
implementation("com.ditchoom:mqtt-4-models:<version>") // MQTT 3.1.1 (v4)
implementation("com.ditchoom:mqtt-5-models:<version>") // MQTT 5.0
}Find the latest version on Maven Central.
The client is in-process: you create a Persistence, register a broker, and start an MqttClient.
It connects, keeps itself connected (automatic reconnection + failover across the broker's connection
options), and persists in-flight QoS 1/2 messages.
import com.ditchoom.mqtt.client.MqttClient
import com.ditchoom.mqtt.connection.MqttConnectionOptions
import com.ditchoom.mqtt.controlpacket.QualityOfService
import com.ditchoom.mqtt.controlpacket.TopicFilter
import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec
import com.ditchoom.mqtt3.controlpacket.ConnectionRequest // v4; use com.ditchoom.mqtt5.* for v5
import com.ditchoom.buffer.Charset
import com.ditchoom.buffer.toReadBuffer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.take
val scope = CoroutineScope(Dispatchers.Default)
// 1. Describe how/where to connect. A broker may list several options for high-availability failover.
val connection = MqttConnectionOptions.SocketConnection(host = "test.mosquitto.org", port = 1883)
val connectionRequest = ConnectionRequest(clientId = "my-client", keepAliveSeconds = 60, cleanSession = true)
// 2. Choose persistence (in-memory here) and register the broker.
val persistence = connectionRequest.controlPacketFactory.defaultPersistence(inMemory = true)
val broker = persistence.addBroker(connection, connectionRequest)
// 3. Start the client (stays connected until you shut it down).
val client = MqttClient.start(scope = scope, broker = broker, persistence = persistence)
client.awaitConnectivity()
// 4. Observe messages on a topic filter. A Codec<P> decodes the payload; OpaquePublishPayloadCodec
// hands you the raw bytes. Register the observer before/at subscribe time.
val messages = client.observe(TopicFilter.fromOrThrow("test/+"), OpaquePublishPayloadCodec)
scope.launch {
messages.take(1).collect { (publish, payload) ->
val text = payload.handle.asReadBuffer().let { it.readString(it.remaining(), Charset.UTF8) }
println("Received on ${publish.topic}: $text")
}
}
// 5. Subscribe, then publish.
client.subscribe("test/+", OpaquePublishPayloadCodec, QualityOfService.AT_LEAST_ONCE).subAck.await()
client.publish(
topicName = "test/123",
qos = QualityOfService.EXACTLY_ONCE,
payload = "hello".toReadBuffer(Charset.UTF8),
)
// 6. Clean up (drain = true waits for in-flight QoS 1/2 acks first).
client.unsubscribe("test/+").unsubAck.await()
client.shutdown(sendDisconnect = true, drain = true)publish, subscribe, and observe are generic over a payload type P and take a Codec<P>:
// Untyped / raw bytes:
client.publish(topicName = "t", payload = bytes /* ReadBuffer */, qos = QualityOfService.AT_LEAST_ONCE)
// Typed — supply a Codec<P> (hand-written or KSP-generated via @ProtocolMessage):
client.publish(topic = "t", payload = myValue, payloadCodec = MyCodec, qos = QualityOfService.AT_LEAST_ONCE)
client.observe(TopicFilter.fromOrThrow("t"), MyCodec).collect { (publish, value) -> /* value: P */ }OpaquePublishPayloadCodec is the built-in codec for raw application bytes. See the
typed payloads guide for defining your own.
All transports produce the same Connection<ControlPacket> and are selected behind a composable
MqttTransport seam, so the rest of the client is transport-agnostic. Pick a transport by the
MqttConnectionOptions subtype (or plug in your own via a custom resolver / connectSingle):
| Option | Transport | Status |
|---|---|---|
SocketConnection |
TCP (+ TLS) | ✅ Ready |
WebSocketConnectionOptions |
WebSocket (+ TLS, permessage-deflate) | ✅ Ready |
QuicConnectionOptions |
QUIC (native) | 🧪 Experimental — implemented (single stream), not broker-tested. Non-standard (EMQX-style); native only. |
WebTransportConnectionOptions |
WebTransport (incl. browser) | 🧪 Experimental — implemented, not broker-tested. The web substitute for QUIC where UDP is unavailable. |
git clone git@github.com:DitchOoM/mqtt.git# Build & test all platforms
./gradlew allTests
# Run specific platform tests
./gradlew jvmTest # JVM tests
./gradlew jsNodeTest # Node.js tests
./gradlew jsBrowserTest # Browser tests
./gradlew testDebugUnitTest # Android unit tests
./gradlew linuxX64Test # Linux native tests
./gradlew macosArm64Test # macOS tests (requires macOS)
./gradlew iosSimulatorArm64Test # iOS tests (requires macOS)
# Linting
./gradlew ktlintCheck # Check code style
./gradlew ktlintFormat # Auto-format code
# Wire-format snapshot gate (fails on breaking MQTT wire-format drift)
./gradlew checkCodecSchema # Verify against committed baseline
./gradlew updateCodecSchema # Accept an intentional wire change
# Publish to local maven
./gradlew publishToMavenLocal
# Get next version
./gradlew -q :mqtt-client:nextVersionSee the open issues for proposed features and known issues,
and TODO.md for tracked follow-ups (QUIC/WebTransport implementation).
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
git checkout -b feature/AmazingFeature)git commit -m 'Add some AmazingFeature')git push origin feature/AmazingFeature)Distributed under the MIT License. See LICENSE for more information.
Coroutines-based Kotlin Multiplatform MQTT 3.1.1 (v4) & 5.0 client. Buffer-backed, zero-copy, backed by 5000+ tests.
This project simplifies managing an MQTT client across multiple platforms with a single Kotlin API.
| Concern | Without | With com.ditchoom:mqtt-client
|
|---|---|---|
| Platform I/O | Separate MQTT stacks per platform | One coroutines API on JVM, Android, iOS/macOS/tvOS/watchOS, JS (browser + Node), Linux |
| Protocol | Pick a v3.1.1 or v5 library | Both MQTT 3.1.1 (v4) and 5.0 behind one client |
| Reliability | Hand-roll reconnect + QoS state | Automatic reconnection, QoS 0/1/2 state machines, message persistence, offline buffering |
| Payloads |
ByteArray copies everywhere |
Zero-copy ReadBuffer + typed Codec<P> payloads |
| Transport | Rewrite for TCP vs WebSocket | Composable transport seam (TCP today; WebSocket, QUIC, WebTransport on the same seam) |
| High availability | Manual failover | A broker can list multiple connection options and fail over between them |
@ProtocolMessage) — no hand-written wire layer — allowing customization or custom protocols derived from MQTT without a full rewrite.ByteArray copies on the hot path.Buffer uses native buffers to pass to the socket:
| Platform | Native Buffer Type |
|---|---|
| Android / JVM | ByteBuffer |
| iOS/macOS/tvOS/watchOS | NSData |
| BrowserJS/NodeJS | ArrayBuffer / SharedArrayBuffer |
| Linux x64/arm64 | NativeBuffer |
Socket uses native socket APIs:
| Platform | Native Socket Impl |
|---|---|
| Android/JVM | AsynchronousSocketChannel (fallback SocketChannel) |
| iOS/macOS/tvOS/watchOS | NWConnection |
| NodeJS | Net module |
| BrowserJS | unavailable (use WebSocket / WebTransport) |
| Linux x64/arm64 | io_uring / epoll |
Persistence uses:
| Platform | Persistence Impl |
|---|---|
| Android/JVM | SQLite via SQLDelight |
| iOS/macOS/tvOS/watchOS | SQLite via SQLDelight using -lsqlite3
|
| NodeJS | In-memory |
| BrowserJS | IndexedDB (SQLite-wasm upcoming) |
| Linux x64/arm64 | SQLite via SQLDelight using -lsqlite3
|
An in-memory Persistence is available on every platform.
DitchOoM Kotlin Multiplatform runtime dependencies:
Codec framework, and the buffer-flow byte layer.Official Kotlin Multiplatform runtime dependencies:
Other runtime dependencies:
| Platform | MQTT 3.1.1 (v4) | MQTT 5.0 | LWT | SSL / TLS | Persistence | Auto Reconnect | Offline Buffering | TCP | WebSocket | Coroutines API | High Availability |
|---|---|---|---|---|---|---|---|---|---|---|---|
JVM |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
Browser |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | ⛔ | 🚀 | 🚀 | 🚀 |
Node.JS |
🚀 | 🚀 | 🚀 | 🚀 | 📝 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
Android |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
iOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
macOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
watchOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
tvOS |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
Linux |
🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 | 🚀 |
🚀 = Ready 📝 = Planned ⛔ = Unavailable (platform API limits)
Published to Maven Central under com.ditchoom.
dependencies {
// The client (pulls in buffer + socket transitively).
implementation("com.ditchoom:mqtt-client:<version>")
// Add the protocol version(s) you need — or both:
implementation("com.ditchoom:mqtt-4-models:<version>") // MQTT 3.1.1 (v4)
implementation("com.ditchoom:mqtt-5-models:<version>") // MQTT 5.0
}Find the latest version on Maven Central.
The client is in-process: you create a Persistence, register a broker, and start an MqttClient.
It connects, keeps itself connected (automatic reconnection + failover across the broker's connection
options), and persists in-flight QoS 1/2 messages.
import com.ditchoom.mqtt.client.MqttClient
import com.ditchoom.mqtt.connection.MqttConnectionOptions
import com.ditchoom.mqtt.controlpacket.QualityOfService
import com.ditchoom.mqtt.controlpacket.TopicFilter
import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec
import com.ditchoom.mqtt3.controlpacket.ConnectionRequest // v4; use com.ditchoom.mqtt5.* for v5
import com.ditchoom.buffer.Charset
import com.ditchoom.buffer.toReadBuffer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.take
val scope = CoroutineScope(Dispatchers.Default)
// 1. Describe how/where to connect. A broker may list several options for high-availability failover.
val connection = MqttConnectionOptions.SocketConnection(host = "test.mosquitto.org", port = 1883)
val connectionRequest = ConnectionRequest(clientId = "my-client", keepAliveSeconds = 60, cleanSession = true)
// 2. Choose persistence (in-memory here) and register the broker.
val persistence = connectionRequest.controlPacketFactory.defaultPersistence(inMemory = true)
val broker = persistence.addBroker(connection, connectionRequest)
// 3. Start the client (stays connected until you shut it down).
val client = MqttClient.start(scope = scope, broker = broker, persistence = persistence)
client.awaitConnectivity()
// 4. Observe messages on a topic filter. A Codec<P> decodes the payload; OpaquePublishPayloadCodec
// hands you the raw bytes. Register the observer before/at subscribe time.
val messages = client.observe(TopicFilter.fromOrThrow("test/+"), OpaquePublishPayloadCodec)
scope.launch {
messages.take(1).collect { (publish, payload) ->
val text = payload.handle.asReadBuffer().let { it.readString(it.remaining(), Charset.UTF8) }
println("Received on ${publish.topic}: $text")
}
}
// 5. Subscribe, then publish.
client.subscribe("test/+", OpaquePublishPayloadCodec, QualityOfService.AT_LEAST_ONCE).subAck.await()
client.publish(
topicName = "test/123",
qos = QualityOfService.EXACTLY_ONCE,
payload = "hello".toReadBuffer(Charset.UTF8),
)
// 6. Clean up (drain = true waits for in-flight QoS 1/2 acks first).
client.unsubscribe("test/+").unsubAck.await()
client.shutdown(sendDisconnect = true, drain = true)publish, subscribe, and observe are generic over a payload type P and take a Codec<P>:
// Untyped / raw bytes:
client.publish(topicName = "t", payload = bytes /* ReadBuffer */, qos = QualityOfService.AT_LEAST_ONCE)
// Typed — supply a Codec<P> (hand-written or KSP-generated via @ProtocolMessage):
client.publish(topic = "t", payload = myValue, payloadCodec = MyCodec, qos = QualityOfService.AT_LEAST_ONCE)
client.observe(TopicFilter.fromOrThrow("t"), MyCodec).collect { (publish, value) -> /* value: P */ }OpaquePublishPayloadCodec is the built-in codec for raw application bytes. See the
typed payloads guide for defining your own.
All transports produce the same Connection<ControlPacket> and are selected behind a composable
MqttTransport seam, so the rest of the client is transport-agnostic. Pick a transport by the
MqttConnectionOptions subtype (or plug in your own via a custom resolver / connectSingle):
| Option | Transport | Status |
|---|---|---|
SocketConnection |
TCP (+ TLS) | ✅ Ready |
WebSocketConnectionOptions |
WebSocket (+ TLS, permessage-deflate) | ✅ Ready |
QuicConnectionOptions |
QUIC (native) | 🧪 Experimental — implemented (single stream), not broker-tested. Non-standard (EMQX-style); native only. |
WebTransportConnectionOptions |
WebTransport (incl. browser) | 🧪 Experimental — implemented, not broker-tested. The web substitute for QUIC where UDP is unavailable. |
git clone git@github.com:DitchOoM/mqtt.git# Build & test all platforms
./gradlew allTests
# Run specific platform tests
./gradlew jvmTest # JVM tests
./gradlew jsNodeTest # Node.js tests
./gradlew jsBrowserTest # Browser tests
./gradlew testDebugUnitTest # Android unit tests
./gradlew linuxX64Test # Linux native tests
./gradlew macosArm64Test # macOS tests (requires macOS)
./gradlew iosSimulatorArm64Test # iOS tests (requires macOS)
# Linting
./gradlew ktlintCheck # Check code style
./gradlew ktlintFormat # Auto-format code
# Wire-format snapshot gate (fails on breaking MQTT wire-format drift)
./gradlew checkCodecSchema # Verify against committed baseline
./gradlew updateCodecSchema # Accept an intentional wire change
# Publish to local maven
./gradlew publishToMavenLocal
# Get next version
./gradlew -q :mqtt-client:nextVersionSee the open issues for proposed features and known issues,
and TODO.md for tracked follow-ups (QUIC/WebTransport implementation).
Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
git checkout -b feature/AmazingFeature)git commit -m 'Add some AmazingFeature')git push origin feature/AmazingFeature)Distributed under the MIT License. See LICENSE for more information.