
Mobile Bluetooth BLE Crash Debugging: Complete Guide
Mobile Bluetooth and BLE connectivity powers everything from fitness trackers to smart home devices, but when your BLE stack crashes on a user's device, you're debugging blind. Bluetooth Low Energy crash debugging on mobile presents unique challenges — hardware-dependent failures, asynchronous GATT callbacks, and platform-specific permission models create crash patterns that conventional crash reporting tools often miss. According to Google's Android Bluetooth documentation, Bluetooth-related exceptions are among the most difficult to reproduce because they depend on external device state, radio conditions, and OS-level resource management. This guide covers the most common Bluetooth and BLE crash patterns on iOS and Android, with platform-specific debugging strategies, code examples, and how Bugspulse helps you catch these failures before your users do.
iOS CoreBluetooth Crash Patterns
CBCentralManager State Restoration Crashes
The most insidious CoreBluetooth crash occurs when iOS restores your app's Bluetooth state after a background termination. If your CBCentralManager initialization doesn't handle the restored state correctly, you'll encounter an EXC_BAD_ACCESS when the system delivers pending events to a partially initialized manager. Apple's CoreBluetooth programming guide warns that state restoration requires explicit opt-in and careful reconnection logic.
// CORRECT: Handle restored state before scanning
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
// Only scan if NOT restored (restored peripherals trigger delegate separately)
guard central.retrieveConnectedPeripherals(withServices: []).isEmpty else {
// System will re-deliver didConnect for restored peripherals
return
}
central.scanForPeripherals(withServices: nil, options: nil)
case .poweredOff:
// Persist pending operations — iOS may kill your app
persistPendingGATTOperations()
case .unauthorized:
// Present permission guidance
break
default:
break
}
}The critical pattern: never call scanForPeripherals during state restoration. Let the system re-deliver didConnect callbacks for peripherals it already knows about, or you'll double-connect and crash with a CBError code 14 (peer removed connection).
Peripheral Deallocation and EXC_BAD_ACCESS
CoreBluetooth uses weak references internally, but your code must maintain strong references to CBPeripheral and CBCharacteristic objects while operations are in-flight. When a peripheral disconnects unexpectedly (common with BLE range limits), accessing its properties after deallocation triggers the dreaded EXC_BAD_ACCESS. Apple's documentation on CBPeripheral notes that peripherals can disconnect at any time without warning.
// Use a strong reference dictionary keyed by UUID
var activePeripherals: [UUID: CBPeripheral] = [:]
func centralManager(_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?) {
// Remove from active set AFTER cleaning up pending operations
defer { activePeripherals.removeValue(forKey: peripheral.identifier) }
// Cancel any pending GATT operations first
peripheral.services?.forEach { service in
service.characteristics?.forEach { characteristic in
if characteristic.isNotifying {
peripheral.setNotifyValue(false, for: characteristic)
}
}
}
// Log the disconnection for crash forensics
logBLEEvent("Disconnect", peripheral: peripheral.identifier, error: error)
}Background BLE Scanning Limits
iOS enforces strict background BLE limits. Apps that scan aggressively in the background face termination by the watchdog. According to Apple's background execution documentation, continuous background BLE scanning without the bluetooth-central background mode is a guaranteed path to a crash report in your Bugspulse dashboard. Always use service UUID filtering and respect the OS-imposed scanning windows.
Android Bluetooth Crash Patterns
Runtime Permission Crashes
Starting with Android 12 (API 31), Bluetooth permissions moved to the runtime model with BLUETOOTH_SCAN, BLUETOOTH_CONNECT, and BLUETOOTH_ADVERTISE. Apps that don't handle denied permissions crash with SecurityException. As noted in Android's Bluetooth permissions guide, the transition has been a leading cause of Bluetooth-related crash spikes on Google Play Console.
// Defensive permission check before any Bluetooth operation
fun startBLEScan() {
val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.ACCESS_FINE_LOCATION
)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
val missing = requiredPermissions.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) {
// Log to Bugspulse instead of crashing
logNonFatalError("BLE_SCAN_PERMISSION_DENIED",
mapOf("missing" to missing.joinToString()))
return
}
bluetoothAdapter.bluetoothLeScanner.startScan(scanCallback)
}BluetoothAdapter Null — The Emulator Trap
The BluetoothAdapter.getDefaultAdapter() returns null on emulators and devices without Bluetooth hardware. A surprising number of production crashes originate from this oversight. Google's own BluetoothAdapter documentation explicitly states the null contract, yet crash reporting tools consistently show NullPointerException at BluetoothAdapter.getDefaultAdapter().startLeScan() as a top crash across thousands of apps.
val bluetoothAdapter = BluetoothManager.getAdapter(context)
?: run {
// Graceful degradation — don't crash
Bugspulse.logNonFatal("BLE_ADAPTER_NULL", mapOf(
"device_has_bluetooth" to context.packageManager
.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE).toString()
))
return
}GATT Status 133 — Connection Supervision Timeout
If you've worked with Android BLE for any length of time, you've encountered the infamous GATT status 133. This error code — officially GATT_CONN_TIMEOUT — occurs when the connection supervision timer expires, typically after the peripheral moves out of range. As discussed in the Android BLE issue tracker, status 133 often masks underlying problems: aggressive connection intervals, poor antenna design on the peripheral, or Android's Bluetooth stack resource exhaustion after too many rapid connect/disconnect cycles.
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
when {
status == 133 && newState == BluetoothProfile.STATE_DISCONNECTED -> {
// Supervision timeout — do NOT immediately reconnect (thrashing)
Bugspulse.logError("BLE_GATT_133", mapOf(
"device" to gatt.device.address.take(6) + "XX:XX",
"connection_attempt" to connectionAttempt.toString()
))
scheduleBackoffReconnect(gatt.device)
}
status == 0 && newState == BluetoothProfile.STATE_CONNECTED -> {
connectionAttempt = 0
gatt.discoverServices()
}
status != 0 -> {
Bugspulse.logError("BLE_GATT_ERROR", mapOf("status" to status.toString()))
}
}
}
private fun scheduleBackoffReconnect(device: BluetoothDevice) {
connectionAttempt++
val delay = min(connectionAttempt * 2000L, 30_000L) // exponential backoff cap
handler.postDelayed({ bluetoothGatt = device.connectGatt(context, false, gattCallback) }, delay)
}ScanCallback Memory Leaks
Android BLE scanning uses callback objects that hold implicit references to their enclosing context. Forgetting to call stopScan() when your Activity or Fragment is destroyed leaks both memory and the Bluetooth scanner — and Android 8+ enforces a 30-minute scanning limit per app, after which further scan attempts silently fail. This creates a class of "silent crashes" where BLE features simply stop working until app restart, as noted in our guide on silent failure debugging.
// Lifecycle-aware scanning with proper cleanup
class BLEFragment : Fragment() {
private var isScanning = false
override fun onStop() {
super.onStop()
if (isScanning) {
bluetoothAdapter.bluetoothLeScanner.stopScan(scanCallback)
isScanning = false
}
}
}Cross-Platform BLE: React Native and Flutter
React Native Native Module Crashes
React Native BLE libraries like react-native-ble-plx bridge Objective-C and Java native modules through the React Native bridge, and bridge serialization failures are common when the native BLE thread delivers callbacks after the JavaScript runtime has been torn down. The react-native-ble-plx GitHub issues show that Tried to send signal over a closed bridge is a recurring crash that requires careful lifecycle management — unregistering BLE listeners in componentWillUnmount.
// Safe BLE listener management in React Native
useEffect(() => {
const subscription = bleManager.onStateChange((state) => {
if (state === 'PoweredOn') startScan();
}, true);
return () => {
subscription.remove();
bleManager.cancelTransaction('monitor');
};
}, []);Flutter Platform Channel Serialization
The flutter_blue_plus package communicates with native BLE stacks through platform channels, and serialization errors occur when the native side sends data structures that don't match the Dart-side expectations. Always wrap platform channel calls in try-catch blocks and report deserialization failures through Bugspulse's Flutter SDK so they surface alongside your native crashes.
Debugging Tools for BLE Crashes
iOS: Xcode Bluetooth Debugging
Xcode provides several BLE-specific debugging tools that most developers never discover. Enable the Bluetooth debug logging profile from Settings → Developer → Bluetooth Debug Logging on your test device. Additionally, install the Apple Bluetooth Profile to capture PacketLogger traces. These .btsnoop files can be opened in Wireshark to replay the exact GATT operations leading up to a crash.
Android: HCI Snoop Log
Android's HCI snoop log captures every Bluetooth Host Controller Interface command and event. Enable it from Developer Options → Enable Bluetooth HCI snoop log, reproduce your crash, then extract the log:
adb bugreport bugreport.zip
# The btsnoop log is at: FS/data/misc/bluetooth/logs/btsnoop_hci.logOpen the .btsnoop file in Wireshark to see the exact GATT ATT protocol exchanges that preceded the crash. Combined with Bugspulse crash reports, this gives you the complete timeline: what the Bluetooth stack was doing and what your app code was executing at the moment of failure.
Prevention Checklist
Before shipping BLE features, verify:
- Permission gating — defensive checks on both iOS (
.bluetoothAlwaysusage description in Info.plist) and Android (runtime permission request flow for API 31+) - Null safety — always handle
BluetoothAdapter.getDefaultAdapter()returning null - Reconnection backoff — never retry immediately on GATT 133; use exponential backoff
- Lifecycle cleanup — unregister scan callbacks and disconnect peripherals in
onStop/onDestroy - State restoration — handle CoreBluetooth restoration without re-scanning
- Monitoring — instrument BLE operations with Bugspulse custom events and breadcrumbs so you can trace every connection, discovery, and GATT operation
Monitoring BLE Crashes with Bugspulse
Tracking Bluetooth crashes requires more than stack traces — you need the sequence of BLE operations that led to the crash. Bugspulse captures breadcrumbs, custom events, and network logs that let you reconstruct the full Bluetooth interaction timeline. When a user reports "the app crashed when my headphones disconnected," you can see exactly which GATT characteristic write was in-flight and whether a connection supervision timeout triggered first.
Instrument your BLE layer once:
// iOS — record every BLE operation as a breadcrumb
Bugspulse.leaveBreadcrumb("BLE Scan Start", metadata: ["services": serviceUUIDs])
Bugspulse.leaveBreadcrumb("BLE Connect", metadata: ["peripheral": peripheral.identifier.uuidString])
Bugspulse.leaveBreadcrumb("BLE GATT Write", metadata: [
"service": characteristic.service.uuid.uuidString,
"characteristic": characteristic.uuid.uuidString
])// Android — same pattern
Bugspulse.leaveBreadcrumb("BLE Scan Start", mapOf("filters" to serviceUuids.toString()))
Bugspulse.leaveBreadcrumb("BLE Connect", mapOf("device" to device.address))
Bugspulse.leaveBreadcrumb("BLE GATT Write", mapOf(
"service" to characteristic.service.uuid.toString(),
"characteristic" to characteristic.uuid.toString()
))With every BLE operation traced, your crash reports become actionable — you'll know whether the crash was caused by a permission denial, a peripheral disconnect mid-write, or a state restoration race condition. Sign up at app.bugspulse.com/register and ship BLE features with confidence.