
Debug MultipeerConnectivity & Nearby Connections Crashes
Peer-to-peer crashes are uniquely painful to debug because there is no server log to consult — the failure lives in the air between two devices, and each side sees only half of the story. On iOS, local device-to-device transfer runs through MultipeerConnectivity — the MCSession, MCPeerID, MCAdvertiserAssistant, and MCBrowserViewController family — and on Android it runs through Nearby Connections with ConnectionsClient, startAdvertising/startDiscovery, and Payload transfer. Both frameworks promise "just works" connectivity over Wi-Fi and Bluetooth, and both can crash in ways a bare stack trace will not explain: a session that deallocates while connected, a peer that disconnects mid-transfer, or a payload sent to an endpoint that is already gone. This guide walks through the concrete crash modes in each stack, with code you can drop in and a strategy for instrumenting the whole connection lifecycle.
Why device-to-device crashes are different
Three properties separate local peer-to-peer failures from ordinary network or UI bugs. First, the state is split across two devices: what your code sees is only ever your own session object, while the crash is often caused by the other device disconnecting, timing out, or sending a malformed payload. Second, the transport is heterogeneous — MultipeerConnectivity multiplexes peer-to-peer Wi-Fi, infrastructure Wi-Fi, and Bluetooth under one API, and Nearby Connections runs over a mix of Bluetooth, BLE, and Wi-Fi Direct — so the same code path behaves differently depending on which radio is actually carrying the bytes. Third, both frameworks are callback-driven and lifecycle-sensitive, so the classic mobile failure modes (delegate lifetime, thread affinity, and use-after-disconnect) appear with local-transfer vocabulary instead of the familiar networking ones. If you have already chased raw radio failures, our guide to mobile Bluetooth BLE crash debugging covers the transport layer that both frameworks sit on top of.
iOS: the MCSession lifecycle
MultipeerConnectivity revolves around three objects: an MCPeerID that identifies your device, an MCSession that represents a group of connected peers, and an MCSessionDelegate that delivers connection and data events. The most common crash in this area is a session lifecycle bug: an MCSession that deallocates while peers are still connected, or a delegate that is released before the session is torn down. When a peer's device goes out of range and reconnects, the framework re-establishes state against objects your code may have already freed, and the result is an EXC_BAD_ACCESS or a callback that silently never fires.
final class PeerSession {
// Retain the session and delegate for the lifetime of the feature.
private let peerID = MCPeerID(displayName: UIDevice.current.name)
private var session: MCSession!
private var advertiser: MCAdvertiserAssistant?
init() {
session = MCSession(peer: peerID,
securityIdentity: nil,
encryptionPreference: .required)
session.delegate = self
}
deinit {
// Tear down in the right order: stop advertising, then disconnect.
advertiser?.stop()
session.disconnect()
}
}The failure usually traces back to two mistakes: creating the session as a local variable so it deallocates right after setup, or never calling disconnect() on teardown so the system believes a connection is still live. Create the session once, store it in a property, and always disconnect() before releasing it — an abandoned session can hold a Bluetooth or Wi-Fi radio open and produce a cascade of reconnect attempts that look like crashes in the next launch.
Delegate thread affinity
MCSession delivers its delegate callbacks on a queue you choose via the delegateQueue property, and if you do not set it, the callbacks arrive on an arbitrary internal queue. Code that mutates UI, a database, or shared state from inside session(_:didReceive:fromPeer:) without marshalling to the right queue is the second most common crash: the work races the main thread, and the symptom is an intermittent crash that only reproduces under real device churn. Set the delegate queue explicitly and marshal UI work to the main thread.
session.delegateQueue = .main // or a dedicated serial queue you control
func session(_ session: MCSession,
didReceive data: Data,
fromPeer peerID: MCPeerID) {
// This runs on the delegate queue — never touch UI directly here.
DispatchQueue.main.async {
self.applyIncomingPayload(data, from: peerID)
}
}iOS: advertising, browsing, and invitations
Discovery is where most developers first hit a crash. The MCAdvertiserAssistant is a convenience wrapper that advertises your device and automatically accepts invitations, while the MCBrowserViewController presents a picker for the user to choose a peer. The two objects have opposite ownership rules, and mixing them up is a common source of bugs: the advertiser assistant must be retained for as long as you want to be discoverable (releasing it stops advertising), while the browser must be presented modally on the main thread and dismissed before it is released.
Invitation handling is the sharpest edge. When you manage invitations manually, the browser calls your MCBrowserViewControllerDelegate, and the session calls session(_:peer:didChange:) with a .notConnected state the instant an invitation is declined or a peer leaves. Code that force-unwraps a connected peer, or sends data immediately after a "connected" callback without checking state, crashes the moment the peer disconnects between the callback and the send.
Sending data: reliability vs streams
MCSession offers two delivery paths, and the crash surface is different for each. The message path, send(_:toPeers:with:), sends a Data blob reliably to a set of peers — but it throws if any peer in the set is no longer connected, so sending to a stale peer array raises an error you must catch rather than ignore. The stream path (startStream(withName:toPeer:)) opens a raw byte stream with no delivery guarantee, and it fails silently when the peer vanishes mid-stream. The fix on both paths is the same: check the peer's state before sending, and wrap every send in a do/catch so a dead peer becomes a handled error instead of a crash.
func send(_ payload: Data, to peerID: MCPeerID) {
guard session.connectedPeers.contains(peerID) else {
// The peer is gone — do not send to it.
handleDisconnected(peerID)
return
}
do {
try session.send(payload, toPeers: [peerID], with: .reliable)
} catch {
// A peer may still race away between the check and the send.
handleDisconnected(peerID)
}
}Android: the ConnectionsClient lifecycle
On Android, Nearby Connections is built around the ConnectionsClient and two callback families: a ConnectionLifecycleCallback that reports connection initiation, acceptance, and disconnection, and a PayloadCallback that reports transfer progress and completion. The single most common Android crash here is reusing a ConnectionsClient after it has been stopped, or sending a payload before the connection has actually been accepted — both produce an ApiException or a NullPointerException that reads like an SDK bug but is really a lifecycle ordering mistake.
class NearbyManager(private val context: Context) {
private val client: ConnectionsClient = Nearby.getConnectionsClient(context)
private val endpointId = "com.bugspulse.nearby" // unique per app
fun startAdvertising() {
val options = AdvertisingOptions.Builder()
.setStrategy(Strategy.P2P_CLUSTER)
.build()
client.startAdvertising(
"My Device", endpointId, lifecycleCallback, options)
.addOnFailureListener { e ->
// ApiException here usually means a bad endpoint or
// advertising already in progress — never ignore it.
logAdvertisingFailure(e)
}
}
}The ordering matters more than most teams expect. startAdvertising and startDiscovery are asynchronous: a connection is only usable once onConnectionInitiated is followed by acceptConnection, and a payload is only sendable once onConnectionResult reports Status.RESULT_OK. Code that fires a payload in onConnectionInitiated before acceptance — or in onConnectionResult before the status is checked — is the Android equivalent of sending to a dead peer on iOS.
Android: Payload transfer
The Payload abstraction unifies three transfer kinds — Payload.fromBytes, Payload.fromFile, and Payload.fromStream — and each has a distinct failure profile. Byte payloads are the simplest but load the whole buffer into memory, so a large file wrapped in a byte payload is a fast path to an out-of-memory kill. File and stream payloads are the opposite: they stream to disk, but the PayloadCallback's onPayloadTransferUpdate fires repeatedly, and writing to a file path that no longer exists, or from a stream that the peer closed mid-transfer, crashes with no friendly message. Treat every payload transfer as a state machine — send, track, and confirm — rather than a fire-and-forget call.
fun sendPayload(endpointId: String, file: File) {
client.sendPayload(endpointId, Payload.fromFile(file))
.addOnFailureListener { e ->
// The peer may have disconnected mid-transfer.
logPayloadFailure(endpointId, e)
}
}
// In PayloadCallback:
override fun onPayloadTransferUpdate(endpointId: String, update: PayloadTransferUpdate) {
if (update.status == PayloadTransferUpdate.Status.FAILURE) {
// The peer went away or the stream broke — recover, do not crash.
handleTransferFailure(endpointId)
}
}The Strategy you choose also shapes the crash profile. Strategy.P2P_CLUSTER creates a mesh where any peer can talk to any other, while P2P_STAR and P2P_POINT_TO_POINT restrict the topology — and a mismatch between the advertising strategy and the discovery strategy on the two devices produces a silent connect-then-drop that teams frequently misdiagnose as a crash in their own code. Both devices must agree on the strategy, and the endpoint IDs must be unique per app.
Cross-platform race conditions
Both frameworks share two structural races that account for most of the remaining "unreproducible" crashes. The first is a disconnect race: a peer disconnects between your state check and your send, so the check passes and the send throws. The second is a reconnect storm: a device on the edge of Wi-Fi range flaps between connected and disconnected, and each flap fires the full disconnect/reconnect callback sequence — code that allocates a session, a stream, or a file on every reconnect leaks until the process dies. The fix on both platforms is identical discipline, and it mirrors what we cover in mobile thread safety: prevent race conditions: keep a single source of truth for connection state, make every transition check-and-set, and make disconnect handling idempotent so a second "peer left" for an already-removed peer is a no-op instead of a crash.
Because the transport is a moving target, a reconnect storm can also masquerade as a network problem — the same flapping we describe in debug mobile WebRTC connection crashes applies word-for-word to peer-to-peer transports, just with MCSession and ConnectionsClient in place of RTCPeerConnection.
Instrumenting the connection lifecycle
A crash report that says "EXC_BAD_ACCESS in MCSession" or "ApiException in ConnectionsClient" is nearly useless on its own, because the interesting state lives on two devices, not in one stack trace. Instrument the lifecycle so every failure carries the connection's context: the peer ID, the connection state at the moment of the crash, the payload type and size in flight, the transport in use (Wi-Fi vs Bluetooth), and the sequence of connect/disconnect events that led up to the failure. Attach those as breadcrumbs before the crash fires, and "crash in session" becomes "send to peer B failed 40ms after peer B disconnected" — the difference between a mystery and a one-line fix.
That is exactly the correlation a purpose-built mobile observability tool gives you. Bugspulse captures device-to-device failures with the peer state, payload, and transport breadcrumbs intact, so you can see whether a transfer died from a dead peer, a deallocated session, or a strategy mismatch instead of guessing from a bare stack trace. If you are still on a generic reporter that drops connection context, see how Bugspulse handles mobile crash reporting and instrument your first peer-to-peer transfer today.
Local device-to-device transfer is brutal because the bug is split across two devices and the transport hides under an abstraction layer that promises more than it delivers. Retain your sessions and clients for the life of the feature, check peer state before every send, wrap sends in error handling, and attach connection context to every breadcrumb — and the next transfer failure will arrive with the whole story attached. Create a free Bugspulse account and ship your next build with the peer-to-peer lifecycle fully instrumented.