Building Production-Grade UDP Transport in Kotlin Multiplatform: The Complete Guide Nobody Wrote
How I designed a zero-overhead, coroutine-safe, fallback-capable real-time networking layer for Android and iOS — and every mistake I made…
Building Production-Grade UDP Transport in Kotlin Multiplatform: The Complete Guide Nobody Wrote
How I designed a zero-overhead, coroutine-safe, fallback-capable real-time networking layer for Android and iOS — and every mistake I made along the way
Tags: kotlin-multiplatform, kotlin, android, ios, networking, udp, coroutines, mobile-development, software-architecture, kmm
I want to start with a confession.
The first time I tried to build UDP transport in KMM, I broke. The iOS app was silently corrupting incoming packets, the Android app was blocking the CPU thread pool, and the fallback to TCP never triggered because the health monitor was comparing against a buffer reference that had already been overwritten.
Nobody told me about any of these problems. The documentation doesn’t cover them. The blog posts don’t go deep enough. Stack Overflow has fragments, not a system.
So I built the system from scratch, broke it in every possible way, fixed each break, and now I’m writing the article I wish had existed when I started.
This is that article.
Whether you’ve never heard of KMM or you’re a senior engineer designing multi-year infrastructure — by the end of this, you’ll understand not just what to build, but exactly why every single decision was made. And more importantly, you’ll understand the traps that look correct but will destroy you at 2 AM.
Before We Start: What Are We Actually Building?
The app in question has three distinct networking needs:
Real-time cloud communication — the app talks to a backend server over UDP. Bi-directional. Session-lifetime. If UDP degrades, we fall back to TCP without the features knowing anything changed.
Real-time IoT communication — the app talks to hardware devices on the local WiFi network. Same bi-directional UDP pattern, but the socket must be explicitly bound to the WiFi interface (more on why this matters later — it’s non-obvious and breaks in production if you skip it).
Temporary sockets — short-lived UDP operations like device discovery broadcasts. Caller-managed lifecycle.
And one hard constraint that shapes everything:
The wire format cannot change. No new bytes. No framing injection. No protocol headers. Byte-for-byte identical payloads.
This rules out most off-the-shelf solutions. Ktor’s socket support is incomplete for this use case. Third-party networking libraries add their own framing. So we build from raw sockets up.
Part 1: Understanding the Problem Space
UDP Is Not What You Think It Is
If you come from HTTP or REST development, UDP feels like a broken version of TCP. Packets can be lost. They can arrive out of order. There is no connection. There is no acknowledgement. You send a packet into the network and you genuinely do not know if it arrived.
But that’s not a bug. It’s the feature.
UDP has no connection handshake. No acknowledgement round-trip. No congestion control overhead. For real-time data — IoT telemetry, device state updates, live control signals — stale data is worse than no data. If a packet containing “device temperature: 23°C” is delayed by 500ms, the newest packet with “device temperature: 24°C” is already more useful. Waiting for the delayed packet is wrong.
UDP gives you the lowest possible latency path from sender to receiver. For real-time systems, it’s the right default.
The flip side: you need to detect when UDP stops working. Not when a packet is lost (that’s normal and expected) — but when the transport itself has degraded so badly that you need to switch to something more reliable.
This is the core challenge we’re solving.
Kotlin Multiplatform’s Fundamental Problem
KMM lets you write shared Kotlin code that compiles to both Android (JVM bytecode) and iOS (native machine code via LLVM). The same business logic, the same data models, the same networking contracts — one codebase.
The problem: Android and iOS have completely different socket APIs.
Android inherits Java’s java.net package. DatagramSocket. InetAddress. High-level, object-oriented, blocking I/O.
iOS has POSIX sockets. socket(). bind(). sendto(). recvfrom(). select(). Raw C API exposed through Kotlin/Native's C interop layer.
These two worlds cannot share socket code. And that’s fine — that’s what expect/actual is for. But the devil is in every single implementation detail.
Part 2: The Architecture — Three Layers of Separation
Before a single line of socket code, we need to establish what communicates with what.
Feature (ViewModel / UseCase)
↓ knows about: PacketType, InboundCodec, OutboundCodec
RealtimeChannel ← the only interface features touch
↓
PacketDispatcher ← routes ByteArray → typed events
FallbackTransport ← UDP primary, TCP backup
├── UdpTransport ← + UdpHealthMonitor
└── TcpTransport ← activated on fallback
↓
UdpSocket (expect) ← platform contract
├── AndroidUdpSocket ← java.net.DatagramSocket
└── IosUdpSocket ← POSIX recvfrom/sendto
The rule is strict and non-negotiable: nothing flows upward. The socket layer never sees a domain type. The transport layer never sees a feature. The feature never sees a socket.
When you violate this rule — and if you’re under deadline pressure you will be tempted to — you create coupling that makes transport switching, testing, and maintenance a nightmare. The discipline pays off within the first week.
The Contract Features Use
interface RealtimeChannel {
val health: StateFlow<ChannelHealth>
fun <T> subscribe(
type: PacketType<T>,
codec: InboundCodec<T>,
): PacketSubscription<T>
suspend fun <T> send(command: T, codec: OutboundCodec<T>)
}
That’s it. A feature subscribes to typed packet events. A feature sends typed commands. A feature observes connection health for UI. The feature has zero knowledge of UDP, TCP, sockets, ports, interfaces, DNS, fallback, or anything below this line.
Here’s what a feature actually looks like:
class ViewModel(
private val cloudChannel: RealtimeChannel
) : ViewModel() {
private var sub: Subscription = NoOpSubscription
init {
sub = cloudChannel
.subscribe(Packets.StateUpdate, Codec)
.collect(viewModelScope) { state ->
_uiState.value = state.toUiModel()
}
}
fun sendCommand(cmd: Command) = viewModelScope.launch {
cloudChannel.send(cmd, Codec)
}
override fun onCleared() = sub.cancel()
}
No UDP. No TCP. No socket. Just domain types flowing in and out. This code does not change when we switch from UDP to TCP, add gRPC, or change the entire transport stack.
Part 3: The Socket Abstraction — Why expect class Is Wrong
This is the first decision that beginners get wrong, and it matters enormously.
The obvious approach is expect class UdpSocket. One class, two implementations. Clean, right?
Wrong. Here’s why.
An expect class in KMM compiles to a concrete class on each platform. You cannot create a subclass of it from commonMain — the actual implementation lives in platform source sets. Which means you cannot write class FakeUdpSocket : UdpSocket in your commonTest. Which means UdpTransport is completely untestable without running on a real device.
For a class that is the foundation of your entire networking stack, that’s unacceptable.
The correct approach:
// commonMain
interface UdpSocket {
suspend fun bind(port: Int, localAddress: String? = null)
suspend fun send(host: String, port: Int, data: ByteArray)
suspend fun receive(buffer: ByteArray, timeoutMs: Long): Int
fun close()
}
expect fun createUdpSocket(): UdpSocket
interface means FakeUdpSocket is just a plain Kotlin class in commonTest. expect fun createUdpSocket() gives each platform exactly one place where a real socket is created. The UdpTransport receives a socket via constructor injection — it never calls createUdpSocket() itself, which is what makes testing possible.
// In tests
class FakeUdpSocket : UdpSocket {
val sentPackets = mutableListOf<ByteArray>()
private val inbound = ArrayDeque<ByteArray>()
fun enqueue(packet: ByteArray) { inbound.addLast(packet) }
override suspend fun receive(buffer: ByteArray, timeoutMs: Long): Int {
if (inbound.isEmpty()) throw TransportTimeoutException()
val packet = inbound.removeFirst()
packet.copyInto(buffer)
return packet.size
}
override suspend fun send(host: String, port: Int, data: ByteArray) {
sentPackets.add(data)
}
// ...
}
Your entire transport stack, tested in milliseconds, no device required.
Part 4: The Exception Model — Why You Cannot Use IOException
Here’s something that trips up every developer coming from Android-only development.
IOException is an Android/JVM class. It does not exist in commonMain. If you write catch (e: IOException) in your shared code, it will not compile on iOS.
SocketTimeoutException — same problem.
ConnectException — same problem.
The solution is a normalized exception hierarchy that lives in commonMain:
sealed class TransportException(message: String, cause: Throwable? = null)
: Exception(message, cause)
class TransportTimeoutException : TransportException("Receive timeout")
class TransportClosedException : TransportException("Socket is closed")
class TransportBindException(val port: Int, cause: Throwable)
: TransportException("Bind failed on port $port", cause)
class TransportSendException(cause: Throwable)
: TransportException("Send failed", cause)
class TransportReceiveException(cause: Throwable)
: TransportException("Receive failed", cause)
Every platform socket implementation catches platform-specific exceptions and re-throws them as one of these types. Nothing above the socket layer ever sees a SocketTimeoutException or a POSIX errno. The whole transport stack above speaks only this normalized language.
The sealed hierarchy also gives you exhaustive when expressions — the compiler will tell you when you're missing a case.
Part 5: Android Implementation — The Dispatcher Trap
The Android implementation uses java.net.DatagramSocket. Simple enough. But there's a trap that will destroy your app's performance silently.
DatagramSocket.receive() is a blocking call. It blocks the calling thread until a packet arrives or the timeout expires.
If you call this on Dispatchers.Default — which is Kotlin's CPU-bound coroutine dispatcher — you are stealing a thread from the shared thread pool and holding it hostage until the timeout. With a 1-second receive timeout and 2 sockets, you're potentially blocking 2 CPU threads permanently. The UI can stutter. Database operations slow down. Other coroutines queue up.
The fix is one line, but you have to know to add it:
override suspend fun receive(buffer: ByteArray, timeoutMs: Long): Int =
withContext(Dispatchers.IO) { // ← This line is mandatory
val s = requireOpen()
try {
s.soTimeout = timeoutMs.toInt()
val packet = DatagramPacket(buffer, buffer.size)
s.receive(packet)
packet.length
} catch (e: SocketTimeoutException) {
throw TransportTimeoutException()
}
}
Dispatchers.IO has a separate, elastic thread pool specifically designed for blocking operations. It won't starve your CPU dispatcher.
Every single blocking socket call — bind(), send(), receive() — must be wrapped in withContext(Dispatchers.IO). No exceptions.
Part 6: iOS Implementation — Three Problems Nobody Documents
This is where most KMM networking attempts fail. The iOS POSIX interop has three separate issues that are not documented anywhere together.
Problem 1: htons Is Invisible
Port numbers must be in network byte order (big-endian). The C function htons() converts from host byte order to network byte order. On all iOS hardware (ARM), this swaps the two bytes.
The problem: Darwin defines htons as an __attribute__((always_inline)) function. Inline C functions have no symbol in the compiled object file. Kotlin/Native cannot bind to them. platform.posix.htons is unresolved.
The fix: implement it yourself.
private fun hostToNetworkShort(port: Int): UShort {
val v = port and 0xFFFF
return (((v and 0xFF) shl 8) or ((v ushr 8) and 0xFF)).toUShort()
}
This is bit-for-bit identical to htons. For port 5000 (0x1388): low byte 0x88, high byte 0x13, result 0x8813. Correct network byte order.
Problem 2: posix_FD_ZERO and posix_FD_SET Don't Exist on Darwin
For the receive timeout, we need select(). select() takes an fd_set bitmask that tells it which file descriptors to watch. You populate this with FD_ZERO and FD_SET.
In Kotlin/Native, the helpers posix_FD_ZERO() and posix_FD_SET() exist — but only on Linux targets. On Darwin (iOS/macOS), they are not generated.
The fix: manipulate the fd_set struct directly.
// Darwin fd_set: struct { __int32_t fds_bits[32]; }
// 32 words × 32 bits = 1024 file descriptor slots
private fun fdZero(set: fd_set) {
for (i in 0 until 32) set.fds_bits[i] = 0
}
private fun fdSet(fd: Int, set: fd_set) {
val word = fd / 32 // which Int32 word
val bit = fd % 32 // which bit in that word
set.fds_bits[word] = set.fds_bits[word] or (1 shl bit)
}
Socket file descriptors are small numbers (typically < 10), so we’re always working in fds_bits[0].
Problem 3: convert() Cannot Infer Its Target Type
Kotlin/Native’s .convert() extension converts between C integer types. But it requires the compiler to infer the target type from context.
addr.sin_family = AF_INET.convert() // FAILS — cannot infer target type
sin_family on Darwin is sa_family_t = UByte. AF_INET is Int. The compiler sees multiple possible conversion targets and gives up.
The fix: be explicit everywhere.
addr.sin_family = AF_INET.toUByte() // Int → UByte
addr.sin_port = hostToNetworkShort(port) // our manual htons → UShort
fromLen.value = sizeOf<sockaddr_in>().toUInt() // Long → UInt
data.size.toULong() // Int → ULong for size_t args
The Complete receive() with All Fixes Applied
override suspend fun receive(buffer: ByteArray, timeoutMs: Long): Int {
val socketFd = requireOpen()
return memScoped {
val tv = alloc<timeval>()
tv.tv_sec = (timeoutMs / 1_000L).convert()
tv.tv_usec = ((timeoutMs % 1_000L) * 1_000L).convert()
val readSet = alloc<fd_set>()
fdZero(readSet)
fdSet(socketFd, readSet)
val ready = select(socketFd + 1, readSet.ptr, null, null, tv.ptr)
when {
ready == 0 -> throw TransportTimeoutException()
ready < 0 -> {
if (errno == EINTR) throw TransportTimeoutException()
throw TransportReceiveException(Exception("select() errno=$errno"))
}
}
val fromAddr = alloc<sockaddr_in>()
val fromLen = alloc<socklen_tVar>()
fromLen.value = sizeOf<sockaddr_in>().toUInt()
val n = buffer.usePinned { pinned ->
recvfrom(
socketFd, pinned.addressOf(0), buffer.size.toULong(),
0, fromAddr.ptr.reinterpret(), fromLen.ptr,
)
}
if (n < 0L) {
if (fd == INVALID_FD) throw TransportClosedException()
throw TransportReceiveException(Exception("recvfrom() errno=$errno"))
}
n.toInt()
}
}
memScoped creates a native memory arena freed when the block exits — no leaks. usePinned tells the GC not to move the ByteArray while the native call is in progress. Both are mandatory.
Part 7: The Data Race Nobody Talks About
Here’s the bug that corrupted packets in production.
The receive loop allocates one buffer and reuses it:
private val receiveBuffer = ByteArray(config.bufferSize) // e.g., 8192 bytes
The original code emitted it directly:
val size = socket.receive(receiveBuffer, timeoutMs)
_incoming.tryEmit(receiveBuffer) // ← DO NOT DO THIS
The subscriber receives a reference to receiveBuffer. But the next iteration of the receive loop immediately overwrites receiveBuffer with the next packet — before the subscriber finishes reading. The subscriber is reading bytes from packet N+1 while thinking they're reading packet N.
This is a classic data race. It manifests as corrupted packets, random decode failures, and state machines landing in impossible states. It is silent — no crash, no exception, just wrong data.
The fix:
val size = socket.receive(receiveBuffer, timeoutMs)
val payload = receiveBuffer.copyOf(size) // ← allocate a fresh array
_incoming.tryEmit(payload)
copyOf(size) allocates a new ByteArray of exactly size bytes — the actual packet size, not the buffer size. For a 256-byte packet in an 8192-byte buffer, you're allocating 256 bytes per packet. Not 8192. The cost is minimal and the correctness is absolute.
Part 8: The Receive Loop — Coroutine Safety Is Not Free
The receive loop is the heart of the transport. Getting it wrong means unresponsive cancellation, leaked goroutines, or silent failures.
private suspend fun receiveLoop() {
while (isActive && !closed) {
try {
val bytesRead = socket.receive(receiveBuffer, config.receiveTimeoutMs)
val payload = receiveBuffer.copyOf(bytesRead)
healthMonitor.onPacketReceived()
_incoming.tryEmit(payload)
} catch (_: TransportTimeoutException) {
continue // ← expected, keep looping
} catch (e: CancellationException) {
throw e // ← ALWAYS rethrow, never swallow
} catch (_: TransportClosedException) {
break // ← clean exit
} catch (e: TransportReceiveException) {
continue // ← transient error, health monitor handles sustained failures
}
}
}
Three things here that beginners miss:
The timeout is not a failure. TransportTimeoutException just means no packet arrived in the window. continue and loop again. The health monitor (which we'll get to) has its own clock. Do not conflate "no packet in this window" with "transport is broken".
**CancellationException must always be rethrown.** This is a fundamental coroutine invariant. Swallowing CancellationException breaks the structured concurrency contract — the parent scope cannot cancel its children, and you end up with zombie coroutines that run forever. Many developers catch Exception and think they're being safe. Catching Exception catches CancellationException. Always rethrow it.
The receive timeout enables cancellation responsiveness. A blocking recvfrom() with no timeout cannot be interrupted by coroutine cancellation. The timeout window (typically 1 second) is how often the loop checks isActive. Shorter timeout = more responsive cancellation, more loop iterations. 1 second is a reasonable default.
Part 9: UDP Degradation Detection — The Hard Part
UDP is connectionless. There is no “connection closed” event. When the network degrades, receive() doesn't throw an error — it just times out. Forever. You need a way to detect that the transport has effectively stopped working.
We use two independent signals:
Signal 1 — Receive Stall Detection
Track the last time ANY packet was received. If the gap exceeds a threshold, the transport is considered stalled.
class UdpHealthMonitor(
private val policy: FallbackPolicy,
private val scope: CoroutineScope,
) {
private var lastReceivedAt = currentTimeMs()
private var consecutiveSendFailures = 0
fun onPacketReceived() {
lastReceivedAt = currentTimeMs()
consecutiveSendFailures = 0
}
fun onSendFailure() { consecutiveSendFailures++ }
fun onSendSuccess() { consecutiveSendFailures = 0 }
private fun checkHealth() {
val stallMs = currentTimeMs() - lastReceivedAt
return when {
stallMs > policy.receiveStallMs -> HealthSignal.STALL_DETECTED
consecutiveSendFailures >= policy.sendFailureThreshold -> HealthSignal.SEND_FAILURE
else -> HealthSignal.HEALTHY
}
}
}
Signal 2 — Send Failure Counting
If N consecutive sends fail without a single successful send in between, the transport is considered failed. UDP send failures indicate: network interface down, no route to host, or local socket error.
Why two signals?
Receive stall catches passive degradation — the remote end stopped sending but we haven’t tried sending yet. Send failure catches active degradation — we tried to send and it failed. Either alone is insufficient.
Tuning the thresholds:
This is where the FallbackPolicy class earns its existence:
data class FallbackPolicy(
val receiveStallMs: Long = 30_000, // 30s for cloud, 5s for IoT
val sendFailureThreshold: Int = 3,
val healthCheckIntervalMs: Long = 5_000,
val autoRestorePrimaryMs: Long = 120_000,
)
For a cloud socket where the server sends heartbeats every 10 seconds, set receiveStallMs = 25_000. That gives you 2.5 heartbeat intervals of tolerance before declaring stall.
For an IoT socket where the device broadcasts every 1–2 seconds, set receiveStallMs = 5_000. You know something is wrong much faster on a LAN.
Part 10: The Fallback Mechanism — flatMapLatest Is the Key
When the health monitor signals degradation, we need to switch from UDP to TCP. The requirement: downstream subscribers must not know this happened. They should just keep receiving packets from whichever transport is currently active.
This is where flatMapLatest is the elegant solution.
private val _activeSource = MutableStateFlow(Source.UDP)
val incoming: Flow<ByteArray> = _activeSource
.flatMapLatest { source ->
when (source) {
Source.UDP -> udp.incoming
Source.TCP -> tcp.incoming
}
}
flatMapLatest cancels the previous inner collector and starts a new one whenever _activeSource changes. Downstream subscribers collect from incoming and are completely oblivious to the switch.
When we want to fall back:
private suspend fun activateFallback(reason: String) {
if (onFallback) return
onFallback = true
_health.value = ChannelHealth.Degraded(
transport = ActiveTransport.TCP,
lossRate = 0f,
fallbackReason = reason,
)
tcp.start() // Start TCP lazily — no TCP connection unless needed
_activeSource.value = Source.TCP // flatMapLatest re-subscribes instantly
}
TCP is started lazily — only when fallback is actually needed. No wasted connections for sessions that never degrade.
Part 11: The IoT Socket — WiFi Binding Is Not Optional
This section is the one that has the least documentation anywhere online and the highest production failure rate.
The IoT device is on the local WiFi network — 192.168.x.x. The mobile device may simultaneously have WiFi and cellular active. When you send a UDP packet to 192.168.1.42 without binding to the WiFi interface, the operating system makes its own routing decision — and on both Android and iOS, it may route that packet through the cellular interface.
A packet to 192.168.1.42 that goes through cellular reaches your carrier's network, not your local WiFi router. The IoT device never sees it. There is no error. The packet just disappears. Your health monitor eventually triggers fallback to TCP, which fails for the same reason, and the user wonders why their app can't find their device even though WiFi is connected.
On Android, we resolve the WiFi IP and bind the socket to it:
class AndroidNetworkInterfaceProvider(private val context: Context) : NetworkInterfaceProvider {
override fun getWifiInterfaceIp(): String? {
val cm = context.getSystemService(ConnectivityManager::class.java)
for (network in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(network) ?: continue
if (!caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue
return cm.getLinkProperties(network)
?.linkAddresses
?.firstOrNull { it.address is Inet4Address }
?.address?.hostAddress
}
return null
}
}
Then when binding the IoT socket:
socket = DatagramSocket(port, InetAddress.getByName(wifiIp))
On iOS, we walk getifaddrs() looking for en0 (always the primary WiFi interface on iOS hardware):
override fun getWifiInterfaceIp(): String? = memScoped {
val ifaddrsPtr = alloc<CPointerVar<ifaddrs>>()
if (getifaddrs(ifaddrsPtr.ptr) != 0) return null
var current = ifaddrsPtr.value
var result: String? = null
try {
while (current != null) {
val iface = current.pointed
if (iface.ifa_name?.toKString() == "en0") {
val addr = iface.ifa_addr
if (addr?.pointed?.sa_family?.toInt() == AF_INET) {
// Extract IPv4 address via inet_ntop...
result = extractIpString(addr)
break
}
}
current = iface.ifa_next
}
} finally {
freeifaddrs(ifaddrsPtr.value) // Always free the linked list
}
result
}
If WiFi is not connected and getWifiInterfaceIp() returns null, the socket falls back to INADDR_ANY (all interfaces). It still works — but packets may be routed through cellular and not reach LAN devices.
Part 12: DNS Resolution — The Silent Performance Killer
Both platforms have a DNS problem hidden inside send().
On iOS, inet_pton() only accepts numeric IP strings. Pass it "api.example.com" and it returns 0 (invalid input) — no DNS query, no error, just silent failure.
The fix is getaddrinfo():
val hints = alloc<addrinfo>()
memset(hints.ptr, 0, sizeOf<addrinfo>().convert())
hints.ai_family = AF_INET
hints.ai_socktype = SOCK_DGRAM
val resultPtr = alloc<CPointerVar<addrinfo>>()
val status = getaddrinfo(host, null, hints.ptr, resultPtr.ptr)
// status != 0 → DNS failed, gai_strerror(status) gives you the reason
On Android, InetAddress.getByName() does DNS resolution — but here's what the documentation doesn't emphasize: on stock Android (AOSP), networkaddress.cache.ttl = 0. DNS caching is disabled. Every single call to InetAddress.getByName() hits the OS resolver.
For a UDP transport sending 10 packets/second, that’s 10 DNS queries/second. Each taking 10–200ms. Your “real-time” transport is now 10–200ms slower than it could be, burning battery, and hammering the OS resolver.
The solution is the same on both platforms: cache the resolved address and invalidate on failure.
// Android
@Volatile private var cachedAddress: InetAddress? = null
private val resolveMutex = Mutex()
private suspend fun resolveAddress(host: String): InetAddress {
cachedAddress?.let { return it } // Fast path: zero allocations
return resolveMutex.withLock {
cachedAddress ?: run {
val resolved = withContext(Dispatchers.IO) {
InetAddress.getByName(host) // DNS only happens here
}
cachedAddress = resolved
resolved
}
}
}
// In send():
val address = resolveAddress(host)
try {
socket.send(DatagramPacket(data, data.size, address, port))
} catch (e: Exception) {
cachedAddress = null // Invalidate on failure
throw TransportSendException(e)
}
Cache the InetAddress object, not the string — re-running getByName() on a cached string would still potentially hit the resolver.
Part 13: TCP Framing — The Detail That Breaks Everything
When fallback to TCP activates, the payload must be byte-for-byte identical to what UDP was sending. No changes to the wire format.
But TCP is a byte stream, not a message transport. There are no packet boundaries. If you send two 100-byte messages, the receiver might get one 200-byte read. Or four 50-byte reads. TCP guarantees delivery and order, but not message boundaries.
The solution: a 4-byte big-endian length prefix before each payload.
[0x00][0x00][0x00][0x64] → length = 100
[100 bytes of payload]
[0x00][0x00][0x01][0xF4] → length = 500
[500 bytes of payload]
This framing happens inside the TCP socket implementation — completely invisible to callers. They call send(data) and receive() just like UDP. The length prefix is added and stripped transparently.
And critically: the payload itself is unchanged. No new bytes in the payload. No protocol injection. The backend receives the same bytes whether they came from UDP or TCP.
One more thing: TCP sends must be serialized. UDP is inherently atomic — one call to sendto() sends exactly one datagram. TCP sends to a stream — two concurrent send() calls from two coroutines will interleave their bytes, corrupting both messages.
private val sendMutex = Mutex()
override suspend fun send(data: ByteArray) {
sendMutex.withLock {
// Write length prefix + payload atomically
writeAll(lengthPrefix(data.size))
writeAll(data)
}
}
Mutex in Kotlin coroutines does not block a thread — it suspends the coroutine. Zero blocking, correct serialization.
Part 14: The Packet Dispatcher — One Channel, Many Features
Multiple features subscribe to the same real-time channel. The cloud socket might carry feature updates, device events, authentication responses, and telemetry — all coming in as raw bytes on the same connection.
How does each feature get only its packets?
The dispatcher uses broadcast-decode:
internal class PacketDispatcherImpl {
suspend fun dispatch(bytes: ByteArray) {
val snapshot = mutex.withLock { subscribers.values.flatten() }
for (entry in snapshot) {
val decoded = runCatching { entry.codec.decode(bytes) }.getOrNull()
?: continue // null = "not my packet", skip
entry.flow.tryEmit(decoded)
}
}
}
For each incoming byte array, every registered codec’s decode() is called. The contract is: decode() returns null for packets it doesn't own, and returns the decoded domain object for packets it does own.
A feature’s codec state might check the first byte of the payload as a message type discriminator. If it doesn’t match, return null. If it matches, deserialize and return the domain object.
With 10–20 registered packet types, this is 10–20 function calls per incoming packet. Each decode() that returns null is a few nanoseconds — typically just a single field comparison. Total overhead per packet is well under a microsecond. Not a performance concern.
This approach also requires zero changes to the wire format. No type header injection. No routing bytes. The backend sends exactly what it always sent, and each feature’s codec knows how to recognize its own packets.
Part 15: The Complete Picture
Here’s how a packet flows from the server to your ViewModel:
Server sends UDP datagram
↓
IosUdpSocket.recvfrom() Android: DatagramSocket.receive()
↓
receiveBuffer (pre-allocated)
↓
receiveBuffer.copyOf(bytesRead) ← NEW allocation, safe to emit
↓
healthMonitor.onPacketReceived()
↓
_incoming.tryEmit(payload)
↓
FallbackTransport.incoming ← flatMapLatest: UDP or TCP
↓
RealtimeChannelImpl dispatch loop
↓
PacketDispatcherImpl.dispatch(bytes)
↓
FeatureCodec.decode(bytes) → FeatureState (non-null = match)
AuthCodec.decode(bytes) → null (not my packet, skipped)
↓
FeatureViewModel.collect { state → _uiState.value = state }
And here’s what happens when UDP fails:
UdpHealthMonitor: no packets for 30 seconds
↓
HealthSignal.STALL_DETECTED
↓
FallbackTransport.activateFallback("UDP stall")
↓
TcpTransport.start() (lazy — first time connecting)
↓
_activeSource.value = Source.TCP
↓
flatMapLatest cancels UDP collector, starts TCP collector
↓
_health.value = ChannelHealth.Degraded(TCP, reason)
↓
FeatureViewModel observes: _networkHealth.value = "Degraded"
Feature code: zero changes. Zero awareness. Zero impact.
The Mistakes That Cost Us Weeks
Let me be direct about the failures before closing.
Emitting the buffer reference was in production for three weeks before we caught it. Packets were being silently corrupted under burst traffic — the kind of traffic that only happens in the hands of real users. The fix is one line. The detection is weeks of confused debugging.
Blocking Dispatchers.Default caused battery drain and UI jank that we attributed to the wrong cause for a month. We thought it was our rendering pipeline. It was socket threads starving the CPU dispatcher.
Not binding to WiFi for IoT meant that in dual-SIM markets (India, Southeast Asia), devices with both a data SIM and WiFi active could not connect to IoT devices at all. The failure was silent — UDP packets simply vanished into the cellular interface. This one took a field trip to physically reproduce.
Not caching DNS on Android was caught in a performance review. The profiler showed InetAddress.getByName() appearing in the hot path during peak telemetry. On stock Android, every call was hitting the resolver.
Every one of these is now addressed in the architecture described above.
What to Read Next
If this article sparked interest, here are the threads worth pulling:
The KMM documentation on C interop is worth reading in full if you’re doing POSIX work. The memScoped, usePinned, and alloc<> patterns are the building blocks of everything in Part 6.
The Kotlin coroutines guide section on structured concurrency explains why CancellationException must never be swallowed — the reasoning is worth understanding deeply, not just following as a rule.
RFC 768 is the actual UDP specification. It’s two pages. Reading it once will clarify more about UDP’s actual guarantees than any blog post.
Closing
Networking is the part of mobile development where the gap between “it works on my machine” and “it works for a million users on a 3G connection in a country with cheap dual-SIM phones” is the widest.
The architecture in this article is not theoretical. Every decision — the expect interface, the copyOf(bytesRead), the select() before recvfrom(), the Dispatchers.IO wrapper, the WiFi binding, the DNS cache — came from a specific real failure.
KMM gives you the ability to write this transport layer once and run it on both platforms. That’s a genuine productivity win. But it also means that every platform-specific quirk — and there are many — has to be understood and handled correctly, or it silently fails on one platform.
Understanding the quirks is what this article is for.
Build something real with it.
If this was useful, follow for more deep-dives into KMM production architecture. I write about the things that don’t make it into documentation — the failures, the fixes, and the reasoning behind decisions that look arbitrary until they’re not.
Tags: kotlin-multiplatform kotlin android-development ios-development networking udp coroutines mobile-architecture kmm software-engineering posix kotlin-native real-time iot mobile-development
Reading time: ~25 minutes
Suggested publication: Better Programming, Level Up Coding, or self-publish
메타데이터
- post_id
- 3a5d7e1fc91f
- slug
- building-production-grade-udp-transport-in-kotlin-multiplatform-the-complete-guide-nobody-wrote-3a5d7e1fc91f
- url
- https://medium.com/@mr.califer/building-production-grade-udp-transport-in-kotlin-multiplatform-the-complete-guide-nobody-wrote-3a5d7e1fc91f
- canonical_url
- https://medium.com/@mr.califer/building-production-grade-udp-transport-in-kotlin-multiplatform-the-complete-guide-nobody-wrote-3a5d7e1fc91f
- author_url
- https://medium.com/@mr.califer
- status
- ok
- fetched_at
- 2026-06-14 11:28:49