React Native BLE Reconnect Logic That Works, handling timeouts, app resume, and “ghost” connections

Your app says it’s connected, but the device won’t respond. The UI spins, users tap again, and now you’ve got two connect attempts racing each other. If you build IoT apps long enough, you learn that a “successful” BLE connect callback can still be a lie.
This post shows a react native BLE reconnect approach that holds up in real usage: timeouts you can cancel, a tiny state machine, app resume handling, and a way to detect and recover from “ghost” connections (connected on paper, dead in practice).
Why reconnect breaks: timeouts, concurrency, and “connected but not usable”
Most reconnect bugs aren’t exotic. They’re the same few problems that show up under bad radio conditions, OS background limits, or an impatient user.
1) No real timeout means no recovery Some connect calls can hang for a long time, especially on Android during GATT churn. If you don’t timebox the attempt, your state gets stuck in Connecting forever, and your retry loop never runs. Patterns for predictable retries and timeouts are covered well in retry and timeout patterns in React Native BLE.
2) Concurrent connects corrupt state A common “works on my phone” issue: a disconnect event triggers reconnect, then the user hits “Reconnect”, then AppState resumes and also reconnects. Without a guard, you can end up with overlapping connectToDevice() calls and a hard-to-debug mess of subscriptions and callbacks.
3) A connection is not a session Many libraries report “connected” before you’ve proven the GATT layer is healthy. A “ghost” connection is when the OS (or library) reports connected, but service discovery stalls, reads time out, or notifications never arrive. Treat “Connected” as “transport is up”, then run a quick validation step to confirm the device is actually usable.
A small TypeScript reconnect manager (Idle, Scanning, Connecting, Connected, Recovering)
The goal is simple: one place owns the connection lifecycle. Everything else asks it for “make sure we’re connected”.
This example assumes react-native-ble-plx, but the structure works with other BLE stacks.
Core types and helpers
type BleState = 'Idle' | 'Scanning' | 'Connecting' | 'Connected' | 'Recovering';
type BackoffOpts = { baseMs: number; maxMs: number; attempt: number };
const sleep = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
});
});
const backoffWithJitter = ({ baseMs, maxMs, attempt }: BackoffOpts) => {
const exp = Math.min(maxMs, baseMs * Math.pow(2, attempt));
const jitter = 0.7 + Math.random() * 0.6; // 0.7x to 1.3x
return Math.round(exp * jitter);
};
const withTimeout = async <T,>(ms: number, work: (signal: AbortSignal) => Promise<T>) => {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), ms);
try {
return await work(ac.signal);
} finally {
clearTimeout(t);
}
};
Reconnection manager with a single-flight connect
Key ideas:
- Single-flight: only one connect/recover runs at a time.
- Recovering state: a deliberate “clean up then retry” mode.
- Validation: don’t trust “connected” until a quick GATT operation succeeds.
import { AppState, AppStateStatus } from 'react-native'; import { BleManager, Device, Subscription } from 'react-native-ble-plx'; export class BleReconnectManager { private manager = new BleManager(); private state: BleState = 'Idle'; private deviceId?: string; private inflight?: Promise; private disconnectSub?: Subscription; private appStateSub?: { remove: () => void };
constructor(deviceId?: string) {
this.deviceId = deviceId;
}
start() {
this.appStateSub = AppState.addEventListener('change', this.onAppState);
}
stop() {
this.appStateSub?.remove();
this.cleanupListeners();
this.manager.destroy();
this.state = 'Idle';
}
async ensureConnected(): Promise<Device> {
if (this.inflight) return this.inflight;
this.inflight = this.connectLoop().finally(() => {
this.inflight = undefined;
});
return this.inflight;
}
private connectLoop = async (): Promise<Device> => {
this.state = 'Connecting';
for (let attempt = 0; attempt < 6; attempt++) {
try {
const device = await withTimeout(12000, (signal) => this.connectOnce(signal));
this.state = 'Connected';
return device;
} catch (e) {
this.state = 'Recovering';
await this.safeReset();
const delay = backoffWithJitter({ baseMs: 400, maxMs: 8000, attempt });
await sleep(delay);
this.state = 'Connecting';
}
}
this.state = 'Idle';
throw new Error('BLE reconnect failed after retries');
};
private connectOnce = async (signal: AbortSignal): Promise<Device> => {
if (!this.deviceId) throw new Error('Missing deviceId');
const device = await this.manager.connectToDevice(this.deviceId, { autoConnect: false });
signal.throwIfAborted?.();
await device.discoverAllServicesAndCharacteristics();
signal.throwIfAborted?.();
this.attachDisconnectListener(device);
await this.validateIsRealConnection(device);
return device;
};
private attachDisconnectListener(device: Device) {
this.cleanupListeners();
this.disconnectSub = device.onDisconnected(() => {
if (this.state === 'Connected') this.ensureConnected().catch(() => {});
});
}
private async validateIsRealConnection(device: Device) {
// Pick one fast “canary” operation your device supports.
// Options: requestMTU (Android), read a known characteristic, or a quick service/char lookup.
// Keep it short and fail fast.
await withTimeout(4000, async () => {
// Example: if you have a known read characteristic, do a read here.
// await device.readCharacteristicForService(SVC_UUID, CHAR_UUID);
return true;
});
}
private async safeReset() {
this.cleanupListeners();
if (!this.deviceId) return;
try {
await this.manager.cancelDeviceConnection(this.deviceId);
} catch {}
await sleep(300);
}
private cleanupListeners() {
this.disconnectSub?.remove();
this.disconnectSub = undefined;
}
private onAppState = (next: AppStateStatus) => {
if (next !== 'active') return;
// On resume, verify and reconnect if needed.
this.ensureConnected().catch(() => {});
};
}
Scan vs connect by known ID, and how to spot ghost connections
A reconnect strategy needs one clear rule: when do you scan, and when do you connect directly?
Connect by known ID when:
- You already paired/selected a device and saved its
deviceId. - You’re reconnecting after a brief drop (out of range, temporary RF noise).
- You want speed and less battery drain.
Scan when:
- You don’t have a saved ID (first-time setup).
- The OS can keep a stale ID around, but the peripheral changed (rare, but happens).
- You suspect you’re holding a stale
Devicereference and want a fresh instance.
On Android, reconnect behavior can be inconsistent across vendors. Real-world reports of out-of-range reconnect oddities show up in threads like BLE-PLX reconnect issue discussion. Use those patterns as a reminder: make your reconnect loop defensive, and don’t assume the platform will “just reconnect”.
Practical ghost-connection checks
A “ghost” is best detected by a validation step you can repeat:
- Service discovery:
discoverAllServicesAndCharacteristics()should complete quickly. - MTU request (Android): failing or hanging can signal a broken GATT session.
- Canary read: read one stable characteristic, with a short timeout.
If validation fails, treat it as disconnected even if callbacks say “connected”. Cancel the connection, wait a beat, then reconnect. If you keep getting ghosts, force a scan and reconnect from the scanned device instance.
Android reconnect quirks: GATT cache, autoConnect traps, stale events, and cleanup
Android BLE can fail in ways that look like your bug, but still require app-side handling.
GATT cache issues Service changes on the peripheral can leave Android with stale services. Many native apps use a hidden “refresh” call, but JS libraries usually don’t expose it. Your practical option is: cancel connection, wait a moment, reconnect, then re-run discovery. If services still look wrong, prompt the user to toggle Bluetooth, or forget and re-pair if bonding is involved.
autoConnect caveats For user-driven reconnect (button press, app resume), autoConnect: false is usually the safer default. autoConnect can delay callbacks and create confusing “connected later” timing, which increases the chance of concurrent connect attempts.
Stale connection events You can see disconnect callbacks fire late, or out of order, after you already started a new attempt. Keep your state machine authoritative. If you’re not in Connected, ignore disconnect events that would trigger another reconnect.
Always remove listeners Every reconnect should clean up notifications, monitor subscriptions, and disconnect listeners. If you don’t, you’ll process the same packet multiple times, and your app will “haunt” itself.
For background cases, keep expectations realistic. If you need OS-level reliability, review platform limits and common timeout patterns (see Android BLE disconnect timeout discussion) and design your UX so reconnect is visible and recoverable.
Logging and telemetry that catch reconnect loops early
Reconnect bugs often vanish when you attach a debugger. Logging is how you keep your sanity.
Log these fields on every state change:
state,attempt,reason(disconnect, resume, user action)- elapsed time for connect, discovery, and validation
- error codes/messages from the BLE library
- device identifiers (ID, name), plus OS and model
Keep one rule: if you hit Recovering more than a few times in a short window, record a single “reconnect loop” event with the last 10 steps. That’s the breadcrumb trail you’ll want when a single Android model misbehaves.
Conclusion
A reliable react native BLE reconnect flow comes from treating BLE like a flaky elevator: one rider at a time, timeboxed waits, and a quick check that the doors actually opened. Use a small state machine, validate “connected” with a real GATT action, reset hard when Android gets stuck, and clean up every listener on the way out. When reconnect fails in the wild, good logs turn it from guesswork into a fix.