React Native BLE in Production: What Nobody Tells You

Set up a BLE connection in React Native and it works in 10 minutes. Then you try it on a QA Android. Scanning returns nothing. On iOS, the connection drops when the app goes to background and never comes back. You find out GPS needs to be enabled on Android just to discover nearby devices.
Nobody warned you.
I've been working with react-native-ble-manager on a production app for over a year. React native ble setups look simple in the docs. They aren't, once you're outside a dev device and a controlled environment.
The Android Permission Shift That Catches Everyone
The most common production surprise right now: your app works on Android 11, breaks on Android 12.
Before Android 12, BLE scanning required ACCESS_FINE_LOCATION. No conceptual reason. It just did. From Android 12 on, Google split the permissions. You need BLUETOOTH_SCAN and BLUETOOTH_CONNECT as runtime dangerous permissions, both of them.
The catch: skip the neverForLocation: true flag in your scan options, and Android 12 still requires location permission on top. Most apps that haven't touched BLE code in the past year are hitting this.
await BleManager.scan(serviceUUIDs, 5, true, {
neverForLocation: true,
});One flag, large blast radius.
For Android 6 through 11: location permission AND GPS must be on at the OS level. Not just granted. GPS turned on. This breaks whenever a user has location disabled for battery. Check the GPS state before scanning. Don't assume it's active because the permission was granted months ago.
React Native BLE Connection State: The Reconnection Myth
Neither iOS nor Android reconnects automatically after a dropped link. You get a disconnect event. Then nothing. Build the reconnect yourself.
BleManager.addListener(
'BleManagerDisconnectPeripheral',
({ peripheral }) => scheduleReconnect(peripheral)
);
function scheduleReconnect(id: string, attempt = 0) {
const delay = Math.min(1000 * 2 ** attempt, 30_000);
setTimeout(async () => {
try {
await BleManager.connect(id);
} catch {
scheduleReconnect(id, attempt + 1);
}
}, delay);
}There's also a worse failure mode than disconnects: reconnects that appear to succeed but don't. connect() resolves. Then discoverAllServicesAndCharacteristics() hangs forever. The device looks connected. It isn't.
The fix: full teardown on every reconnect. Explicitly disconnect first, even if you think the link is already gone. Then reconnect from scratch. Don't trust connection state alone.
iOS and Android: More Different Than the Docs Suggest
iOS never times out BleManager.connect(). If the peripheral is off or out of range, the call waits indefinitely. No built-in timeout. Your UI hangs with no feedback until you add one.
const connectWithTimeout = (id: string, ms = 10_000): Promise<void> =>
Promise.race([
BleManager.connect(id),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('BLE connection timeout')), ms)
),
]);Android background scanning requires a foreground service with a persistent notification from Android 8 on. If your companion app needs to discover devices while backgrounded, account for the foreground service setup. It's not optional.
Scan filtering is also platform-specific. Passing serviceUUIDs on iOS restricts the scan to peripherals advertising that service. On Android, the same parameter is silently ignored. You get all nearby BLE devices regardless. In a busy environment, filter on your side for Android or you'll surface irrelevant devices.
I cover how to structure codebases so agents can navigate them in the agentic coding course.
What Actually Works in Production
BLE behavior is not standardized across hardware. Different chipsets handle timing, MTU negotiation, and reconnection differently. The same code behaves differently on a Samsung mid-range, a Pixel, and a budget MediaTek device. Test on at least three Android devices before shipping.
Don't test on an emulator. BLE doesn't work in any emulator, iOS or Android. You'll spend time diagnosing issues that don't exist on real hardware.
For iOS background mode: if you need to keep a connection alive while the app is backgrounded, declare bluetooth-central in Info.plist background modes. Without it, iOS suspends the app within seconds of backgrounding. With it, you get state restoration. iOS can even relaunch your app when a known peripheral connects.
The 30-second background execution window still applies. For bulk transfers, chunk the data around that constraint.
The architecture that's held up: one context provider for all BLE state. Every BleManager.addListener call lives there. Components subscribe to state and dispatch actions. They never touch BleManager directly.
const BLEContext = createContext<BLEContextValue>(null!);
export function BLEProvider({ children }: { children: React.ReactNode }) {
const [peripherals, setPeripherals] = useState(new Map<string, Peripheral>());
// All BleManager listeners centralized here.
// Components see state, not the library.
}When a reconnect happens in the background, the component doesn't know. It just sees the connection come back. The provider handles the complexity invisibly.
BLE in React Native isn't algorithmically hard. It's hard because the platforms disagree on most of the details, and the documentation skips the production edge cases entirely.
I cover codebase structure for agents and scaling workflows in the agentic coding course.
Learn the agentic coding workflow
I use in production
How I set up my repos, manage context, and run agents in production. Written down so you can do the same.