Fixing Android GATT Cache In React Native BLE Apps

Your BLE device didn't change, yet Android still sees the old services. That's often the android gatt cache holding onto an outdated attribute table.
In React Native, this feels random. A bonded device reconnects, discovery finishes, and the characteristic you need is missing. After a DFU, the bug can look even stranger. The fix usually isn't a magic wipe. It starts with a cleaner connection flow and a clear line between supported Android APIs and hidden hacks.
How stale GATT data shows up on Android
Real cache problems usually appear after the peripheral changed its GATT database. Common triggers are firmware updates, bond removal and re-pairing, or a device that now exposes different handles for the same UUIDs.
The hard part is that cache bugs look like normal GATT bugs. Fast reconnects can return old services. Reusing characteristic objects from a prior session can fail silently. On top of that, overlapping reads and writes can make service discovery look broken when the queue is the real problem.
If the device changes services over time, the peripheral should implement the Service Changed characteristic for bonded clients. Without that signal, Android may keep trusting the old map longer than you expect.
After a DFU, reconnecting faster often makes things worse. Close the old GATT, wait, then discover services again.
A simple rule helps. If the issue only starts after DFU or bond changes, suspect cache. If the peripheral never changed services, suspect timing, queueing, or a library lifecycle bug first.
What Android supports, and what it doesn't
In 2026, the line is still simple. Android gives you public APIs to connect, discover services, request MTU, disconnect, and close a GATT client. It does not give you a public cache reset API in the BluetoothGatt API reference.
It helps to separate safe recovery from unsupported cache wipes:
| Approach | Support status | When it helps | | ---------------------------------------------------------- | ---------------------- | --------------------------------------------------------------- | | Disconnect, close, reconnect, then discover services again | Public and supported | Default recovery after errors or device changes | | Remove bond and re-pair, while relying on Service Changed | Public or system-level | Best for bonded devices after GATT database changes | | Reflection call to BluetoothGatt.refresh() | Hidden and unsupported | Last resort, Android only, may fail across versions and vendors |
That last row matters in React Native. If you expose a refreshGatt helper from Kotlin or Java, you're almost always using reflection against a hidden API. It may work on one phone, then fail on another. It can also break after an Android update.
The gap is real. For example, a react-native-ble-plx cache refresh request is still discussed. Still, that doesn't turn refresh() into a supported production strategy. Use supported flows first, and treat reflection as an opt-in escape hatch, not the backbone of your app.
React Native patterns that reduce GATT cache pain
JavaScript can start the flow, but Android owns the GATT state. Because of that, many teams add a small native module for two jobs: making sure close() happens after disconnect, and surfacing bond state changes that JS libraries may not expose clearly.
A safer reconnect flow is usually enough:
- Cancel notifications and pending GATT work.
- Disconnect, then release the native GATT client.
- Wait a short backoff, often 300 to 1000 ms on touchy stacks.
- Reconnect, then wait for bonding to finish if pairing is involved.
- Request MTU only if needed, then run service discovery once.
- Rebuild service and characteristic references from the new discovery result.
In react-native-ble-plx, that means treating every reconnect as fresh. Call await device.connect(), then await device.discoverAllServicesAndCharacteristics(), and query characteristics again by UUID. Don't keep a characteristic object from the last session and hope it's still valid.
A recent reconnect persistence issue in react-native-ble-plx is a good reminder that reconnecting by stored device ID doesn't guarantee fresh state. You still need proper discovery.
When a native module makes sense
If your app can't reliably sequence disconnect() and native close() from JS, a thin Android bridge helps. Keep it boring. One method like reconnectFresh(deviceId) can serialize teardown, backoff, reconnect, and discovery on the native side.
Also, keep MTU in its place. A successful requestMtu() does not refresh the cache. It only changes packet size. Likewise, bonding changes security state, not service freshness, unless the device also sends Service Changed or the bond gets rebuilt.
How to debug the real cause before blaming the cache
Start with logs, not guesses. Capture connection status codes, bond state transitions, MTU callbacks, onServicesDiscovered status, and the exact service and characteristic UUIDs returned on every connect. A quick adb logcat filter for BluetoothGatt and BtGatt often shows whether discovery ran at all.
For hard cases, compare two runs, one before bond removal and one after. If the bug disappears when the bond is removed or the device is forgotten, cache is a strong suspect. If it disappears after you serialize GATT work, the queue is the problem instead. The react-native-ble-manager concurrency issue shows how easy it is to confuse those two.
Also test on more than one phone. Pixel and major OEM devices often differ in reconnect timing and bond restore behavior. Android 12 through 16 also changed BLE permission and background rules, which can hide the real failure if scans or connects stop early.
Android BLE gets messy when service data changes, but the answer isn't to swing at the cache first. Fresh service discovery, correct bond handling, and a real GATT close solve more production bugs than hidden reflection ever will.
When the peripheral sends Service Changed and your React Native app reconnects with discipline, stale services stop feeling random.