AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Debug Socket & UDP Networking Crashes on iOS & Android

NFNourin Mahfuj Finick··8 min read

When a mobile app talks to a backend over HTTPS, URLSession and OkHttp hide almost everything behind a friendly API. Drop one level lower to raw sockets, and an entirely different class of networking crashes surfaces: UDP datagrams that silently vanish, socket file-descriptor leaks that exhaust the process, and Network.framework connections torn down from the wrong dispatch queue. This guide walks through the socket-level and transport-layer crash patterns that crash reporters like BugsPulse surface most often on iOS and Android, with concrete fixes for NWConnection, BSD sockets, DatagramSocket, and SocketChannel.

The difference matters because an HTTP client manages its own connection pool, retries, timeouts, and thread hops for you. A raw socket gives you none of that. Every read, write, connect, and close is your responsibility, and the failure modes are harsher: a process can be killed by a SIGPIPE signal, starved by an ANR after a blocking recv on the main thread, or pushed past its file-descriptor limit until every subsequent socket call fails. If you are debugging crashes at the HTTP layer instead, our URLSession and OkHttp crash guide covers that side of the stack.

The iOS socket stack: Network.framework and BSD sockets

NWConnection: asynchronous by design, crashy when misused

Network.framework is Apple's modern replacement for raw BSD sockets, built around NWConnection, NWListener, NWPathMonitor, and the NWProtocolTCP and NWProtocolUDP parameter objects. It is fully asynchronous: you create a connection, attach a stateUpdateHandler, and start it on a specific queue. The most common crash pattern is touching connection state from a queue other than the one you passed to start(queue:), or capturing a torn-down connection inside a completion handler that outlives the connection itself.

let conn = NWConnection(host: "relay.example.com", port: 5555, using: .udp)
conn.stateUpdateHandler = { state in
    switch state {
    case .ready: print("connected")
    case .failed(let error): print("failed: \(error)")
    case .waiting(let error): print("waiting: \(error)")
    default: break
    }
}
conn.start(queue: DispatchQueue(label: "network.queue"))

Every state transition arrives on that dedicated queue. If your completion handler mutates UI or a shared model from there, you have a thread-safety bug waiting to become a crash. Send work back to the main queue explicitly, and always cancel the connection before releasing your last strong reference so no callback can fire against a deallocated object.

NWPathMonitor and NWListener callback threading

NWPathMonitor reports interface and connectivity changes (cellular, Wi-Fi, wired) through a handler that, by default, runs on its own serial queue. The classic failure is reading the monitor's currentPath from the main thread while the update handler mutates it from the monitor queue. The same rule applies to NWListener: the newConnectionHandler fires on the queue you give it, and every accepted connection must be started and cancelled on that same queue.

BSD sockets on iOS: getaddrinfo, connect, send, recv, close

Underneath Network.framework, BSD sockets remain available through the Darwin module. The sequence is getaddrinfo, then socket, connect, send and recv, and finally close, and each step has a specific failure signature. Three traps cause the bulk of production crashes. Writing to a peer that has already closed the connection raises SIGPIPE, which by default terminates the process; disable it with signal(SIGPIPE, SIG_IGN) or the MSG_NOSIGNAL send flag. A recv that returns 0 is not an error, it means the peer closed gracefully, so treating it as a failure and retrying in a loop is a guaranteed hang. And partial reads and writes are the rule, not the exception, so send may write fewer bytes than you asked and you must loop until everything is flushed.

signal(SIGPIPE, SIG_IGN); // never die because the peer hung up
 
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n == 0) {
    // Peer closed the connection cleanly — EOF, not an error.
} else if (n < 0) {
    // errno: EAGAIN would block, ECONNRESET peer died, EINTR retry.
}

GCDAsyncSocket, CocoaAsyncSocket, and BlueSocket

Most iOS apps do not call BSD sockets directly; they wrap them. CocoaAsyncSocket (GCDAsyncSocket) is the most widely deployed, while BlueSocket is a pure-Swift alternative. GCDAsyncSocket is delegate- and dispatch-queue-based, and its two recurring crash sources are reading on the wrong queue (the delegate methods run on the socket's delegateQueue) and issuing a second readData before the first completes, which corrupts the internal read stream state.

The Android socket stack: java.net, NIO, and native sockets

java.net.Socket and ServerSocket: the main-thread trap

Socket and ServerSocket look innocent, but a blocking read on the main thread triggers NetworkOnMainThreadException under StrictMode and, in release builds without StrictMode, an ANR that the system treats as a crash. Every socket read or write belongs on a background executor or a coroutine dispatcher.

val socket = Socket()
socket.soTimeout = 10_000
socket.connect(InetSocketAddress(host, port), 10_000)
 
val reader = socket.getInputStream().bufferedReader()
val line = reader.readLine() // blocks — never on the main thread

DatagramSocket and DatagramPacket: UDP's no-handshake reality

UDP has no handshake, no ordering, and no delivery guarantee, and DatagramSocket reflects that. A datagram can be lost, duplicated, or truncated, and your receive buffer must be sized for the maximum payload or the excess is silently dropped. The practical ceiling is about 1472 bytes of payload for a standard 1500-byte MTU once IP and UDP headers are accounted for, a limit the UDP specification leaves to the host to enforce. A truncated datagram then looks like a crash in your parser, not in the network layer.

val socket = DatagramSocket(null)
socket.reuseAddress = true
socket.bind(InetSocketAddress(port))
socket.soTimeout = 5_000
 
val buffer = ByteArray(2048)
val packet = DatagramPacket(buffer, buffer.size)
socket.receive(packet) // blocks up to soTimeout, then throws SocketTimeoutException

Set soTimeout on every UDP socket. Without it, a lost datagram means receive blocks forever, and on the wrong thread that is another ANR.

SocketChannel, Okio, and LocalSocket

SocketChannel gives you non-blocking, selector-driven I/O, the right tool when one thread must service many connections. Okio buffers sit on top of raw sockets and are what OkHttp uses internally; a read loop that ignores a zero-byte result spins forever. LocalSocket is the Unix-domain variant for same-device IPC, and it fails fast with broken-pipe errors if the peer process exits. Finally, any app that speaks a plaintext protocol over a raw socket must whitelist the domain in a network security config, or Android's cleartext policy blocks the connection before it ever leaves the device.

<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">relay.example.com</domain>
    </domain-config>
</network-security-config>

The crash patterns that span both platforms

Socket lifecycle races

The single biggest cross-platform killer is tearing down a socket from one thread while another thread is mid-read. The close frees the file descriptor, the kernel recycles the descriptor number to a new file, and the in-flight read now consumes bytes from an unrelated file or returns EBADF. The fix is a strict ownership rule: exactly one owner closes the socket, and that owner first signals readers to stop and joins them before closing.

File-descriptor leaks: EMFILE and EADDRINUSE

Every socket is a file descriptor, and mobile processes run with a limited descriptor budget. A loop that opens connections without closing them eventually fails with EMFILE (too many open files), after which unrelated parts of the app such as image decoding, database access, and file I/O start failing too. Rebinding a UDP or TCP port without SO_REUSEADDR raises EADDRINUSE on restart. Set the reuse flag before bind, and audit descriptor growth with a quick lsof or /proc pass when a leak is suspected.

  # Count open sockets for a suspicious process
lsof -p <pid> | grep -c socket
adb shell "ls /proc/<pid>/fd | wc -l"

SIGPIPE, EOF, and the zero-byte read

On both platforms, writing to a socket whose peer has closed triggers SIGPIPE or a broken-pipe error. The POSIX signal documentation lists the default action as terminating the process. Suppress it once at startup, and in your read loops distinguish clean EOF (recv or read returns 0) from a genuine error (a negative return with errno set).

UDP vs TCP: truncation, loss, and the delayed-ACK stall

TCP hides packetization, but its reliability has costs: Nagle's algorithm combined with delayed ACK can stall small writes for tens of milliseconds, which shows up as a socket that appears hung. UDP exposes everything: datagram loss, reordering, and truncation above the path MTU. If a UDP payload must arrive intact, you add your own sequence numbers, retransmission, and fragmentation, otherwise a parser that trusts UDP delivery will crash on truncated or out-of-order datagrams.

Timeouts, keepalive, and framing

A socket without SO_TIMEOUT will block forever on a dead peer that never sends a FIN. TCP keepalive detects half-open connections only after minutes, so application-level timeouts and heartbeats are essential. And because TCP is a byte stream, a single recv may return half a message or two messages; you must frame with a length prefix or a delimiter and loop until a full frame is buffered.

A debugging workflow for socket-level crashes

Start from the errno or exception, not the stack trace. EBADF means a use-after-close race; EMFILE means a descriptor leak; ECONNRESET and EPIPE mean the peer died; EAGAIN means a non-blocking socket had nothing ready. Then attach breadcrumbs to every socket lifecycle event, connect, read, write, and close, with the thread name and descriptor number, so a crash monitoring tool like BugsPulse can correlate the crash with the exact network event that preceded it. For crashes that only reproduce on specific networks, our network transition debugging guide covers the Wi-Fi and cellular handoff cases, and the WebSocket connection crash guide covers the application-layer sibling of these same races.

Fix the socket bugs your users are hitting

Socket-level networking crashes are some of the hardest to reproduce because they depend on the network, the peer, and the thread interleaving. The fixes are mechanical once you see them: pin each socket to one owner and one queue, suppress SIGPIPE, distinguish EOF from error, set timeouts and reuse flags, and frame your streams. Catch them early with BugsPulse and sign up free to start seeing every socket crash with full breadcrumbs today.