Your Android BLE connection fails with GATT error 133
By Shailendra Kumar Ram · · 9 min read
Status 133 is not a diagnosis. GATT error 133, or GATT_ERROR, is a catch-all inside Android's native Bluetooth stack: it reports that a BLE connection attempt ended badly and declines to say why. Every other status onConnectionStateChange can hand you narrows the problem down. This one does not. So the job is not looking up what 133 means, because it does not mean anything in particular. The job is working out which of about six unrelated failures you are actually looking at.
D/BluetoothGatt: onClientConnectionState() - status=133 clientIf=7 device=XX:XX:XX:XX:XX:XX
// and in your own callback
onConnectionStateChange(gatt, status = 133, newState = STATE_DISCONNECTED)Two things in that log line are worth more than the 133 itself: how long the attempt ran before it arrived, and the value of clientIf.
133 is GATT_ERROR, not a Bluetooth error
It is worth being precise about where the number comes from, because it explains why it is so unhelpful. The Bluetooth specification does define a list of controller error codes, and as of Core 5.4 they run from 0x01 to 0x47. 0x85, which is 133 in decimal, is not in that list and never has been. Whatever went wrong, the radio did not name it.
What 0x85 sits inside is the ATT protocol's application error range, 0x80 to 0x9F, which the specification reserves for higher layers to define codes of their own. Android used it for its entire internal status enum. In AOSP the line reads GATT_ERROR 0x85, and its neighbours are GATT_BUSY, GATT_WRONG_STATE, GATT_DB_FULL and GATT_NO_RESOURCES: a set of conditions the stack knows how to distinguish internally. GATT_ERROR is the one it falls back to when it will not commit to any of them.
Now look at the public API. BluetoothGatt declares GATT_SUCCESS, GATT_CONNECTION_TIMEOUT, GATT_FAILURE and a handful of ATT-level codes. There is no constant for 133 anywhere in the framework, which means you are reading a private value through a public callback that never agreed to deliver it. That is the real shape of the problem, and it is why the number resists interpretation.
It also kills the most common piece of folklore about it. Android has a specific, public code for a connection timeout: GATT_CONNECTION_TIMEOUT, which is 0x93, or 147. If the stack had concluded you timed out, it had a way to say so. A 133 is not that conclusion.
| Status | Where it comes from | What it tells you |
|---|---|---|
| 0 | Framework, public | GATT_SUCCESS. The operation completed. |
| 8, 19, 22 and other low values | The controller, per the Bluetooth spec | A named link-layer outcome: 8 is a connection timeout, 19 is the peer terminating the link, 22 is the local host terminating it. |
| 133 | Native stack, undeclared in the framework | GATT_ERROR. Nothing specific. The stack declined to classify the failure. |
| 147 | Framework, public | GATT_CONNECTION_TIMEOUT. A timeout the stack was willing to name. |
| 257 | Framework, public | GATT_FAILURE. A generic framework-level failure, distinct from 133. |
Time the failure before you debug it
The single most useful split in this whole problem is how long the connection attempt ran. Nordic's Android BLE library encodes the same instinct as a constant: CONNECTION_TIMEOUT_THRESHOLD, set to 20000 milliseconds, with a comment saying it is the time after which a 133 or a 147 is treated as a timeout rather than as a different reason. A vendor shipping production BLE code decided that late failures and early failures are different bugs.
The number behind that instinct is in AOSP. When you call connectGatt with autoConnect set to false, the stack arms a timer defined as DIRECT_CONNECT_TIMEOUT, thirty seconds, and gives up when it expires. A 133 arriving somewhere near the thirty second mark is usually that timer, and a timer expiring means the peripheral never showed up. It was not advertising, it was out of range, it was already connected to something else, or it was refusing the connection.
A 133 that arrives in the first second or two is the opposite kind of bug. Nothing waited for anything. That is your side: a leaked client interface, a call made from the wrong place, or a stack that was still busy with the previous connection.
autoConnect is a connection strategy, not a retry flag
The parameter is badly named and almost everyone reads it as "reconnect for me automatically". It does not mean that. It selects between two genuinely different mechanisms, described in the AOSP javadoc as actively connecting versus passively scanning and finalising the connection when the device becomes available.
| Property | autoConnect = false | autoConnect = true |
|---|---|---|
| Mechanism | Direct connect: the stack actively pursues the device | Passive: the stack waits for the device to appear |
| Timeout | Thirty seconds, then 133 | None. It waits indefinitely |
| Concurrency | One pending direct connection at a time | Multiple pending attempts allowed |
| Requirement | Works on a device seen once in a scan | The device generally needs to be already known or bonded |
| Speed | Fast when the device is present | Slower to connect, cheaper to leave running |
The practical trap is the last row. Passing autoConnect=true for a device the phone has never seen tends to do nothing at all, because the stack has no cached record to match against. Checking device.getType() for TYPE_UNKNOWN before you connect will tell you that you are in that situation.
There is no authoritative Google guidance on which to prefer, and the two most careful writeups on Android BLE, van Welie's and Punch Through's, disagree about the emphasis. Treat it as a trade-off rather than a rule: direct connect when you have just seen the device in a scan and want it now, passive when you are waiting for a known peripheral to come back into range and have no deadline.
close() is not disconnect(), and client interfaces are a finite pool
This is the cause that produces the fast, inexplicable 133 on a reconnect deep into a session, after everything else worked fine. The two teardown methods do different jobs, and the documentation is oddly quiet about the consequence of skipping one. disconnect() is described as disconnecting an established connection or cancelling one in progress. close() is described only as closing the client, with the advice to call it as early as possible.
What the javadoc never says is what close() releases. That is in the native header: GATT_MAX_APPS is 32, with a comment noting that two are used internally for GATT and GAP. So the pool of client interfaces available to applications is thirty, and every connectGatt that is never closed holds one until the Bluetooth process restarts. Run out and new connections fail immediately, with 133.
The constant lives in the Bluetooth stack rather than in your app, so the pool is shared across everything on the phone. That would explain why a debugging session with a scanner app open in the background can change your results. That is reasoning from where the constant sits rather than documented behaviour.
You can see the leak directly. Filter logcat for onClientRegistered and watch the clientIf value across a few connect and disconnect cycles:
D/BluetoothGatt: onClientRegistered() - status=0 clientIf=6
D/BluetoothGatt: onClientRegistered() - status=0 clientIf=7
D/BluetoothGatt: onClientRegistered() - status=0 clientIf=8 // climbing, not reusedIf that number climbs and never comes back down, you have found it. The ordering of the fix matters as much as the fix:
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_DISCONNECTED) {
// Only now is the interface actually free to release.
gatt.close()
activeGatt = null
}
}
fun teardown() {
// Ask for the disconnect, then wait for the callback above.
// Calling close() here instead would deregister the callback
// before the disconnect is reported, and you would never see it.
activeGatt?.disconnect()
}Call disconnect(), wait for onConnectionStateChange, then close(), then drop the reference. Calling close() on its own leaves a pending passive connection attempt alive underneath you. Calling both back to back synchronously deregisters the callback before the disconnect is reported, which is how teardown bugs become invisible.
Do not re-enter the stack from inside onConnectionStateChange
The advice you will find everywhere is that connectGatt must be called on the main thread. I could not find a source for that, and the mechanism underneath is more specific and more useful.
BluetoothGatt dispatches your callbacks through a helper that either posts to a Handler you supplied or, if you supplied none, runs the callback immediately. With no Handler, your BluetoothGattCallback executes inline on the incoming binder thread, not on the main thread and not on a thread you control. Calling straight back into the stack from there, to retry a connection or start service discovery, means re-entering it from inside its own delivery path while it is still busy.
So the rule is not about the main thread. It is that no GATT call should originate synchronously from inside a GATT callback. Give the stack a Handler and post your retries:
private val bleHandler = Handler(Looper.getMainLooper())
// The overload that takes a Handler exists precisely so callbacks
// stop arriving on whatever binder thread delivered them.
activeGatt = device.connectGatt(
context,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
BluetoothDevice.PHY_LE_1M_MASK,
bleHandler,
)
private fun retryConnect(device: BluetoothDevice, attempt: Int) {
if (attempt > MAX_ATTEMPTS) return giveUp()
bleHandler.postDelayed({
activeGatt = device.connectGatt(
context, false, gattCallback,
BluetoothDevice.TRANSPORT_LE,
BluetoothDevice.PHY_LE_1M_MASK,
bleHandler,
)
}, 500L * attempt)
}Nordic's library does exactly this, posting its retry with a delay rather than calling into the connect path from the callback it just received. And a retry is not an admission of defeat here. Both of those writeups end in the same place: after the real causes are eliminated, some 133s remain, and the answer to those is to close the client, wait, and try again.
When the phone is not the problem
Almost everything written about 133 assumes the bug is in the Android app. Often it is not. The stack reports 133 for a connection that fell apart during establishment, and the peripheral gets a vote in that.
For a concrete, vendor-tracked example: Espressif issue 13058 describes BLE throughput collapsing and clients disconnecting with GATT error 133 after an ESP-IDF point release, with no application change, when WiFi was active on the same chip. Radio coexistence on the peripheral, surfacing on the phone as an unclassified failure. It is closed as done, though without a public explanation of the fix.
The other peripheral-side lever is connection parameters. Android asks for specific intervals depending on the priority you request, and those values are in the platform config rather than in the documentation:
| requestConnectionPriority | Raw config value | Interval |
|---|---|---|
| CONNECTION_PRIORITY_HIGH | 9 to 12 | 11.25 to 15 ms |
| CONNECTION_PRIORITY_BALANCED | 24 to 40 | 30 to 50 ms |
| CONNECTION_PRIORITY_LOW_POWER | 80 to 100 | 100 to 125 ms |
The supervision timeout is the companion setting, and its legal range is 100 ms to 32 seconds. A peripheral asking for something aggressive at one end of that range, on hardware whose radio is also doing something else, is a link that drops under load and reconnects into a 133. If your peripheral is an ESP32, the reasons it behaves differently across platforms are worth reading alongside this.
The cache, refresh(), and the fix that is actually supported
Android caches a bonded peripheral's service table. Change the GATT database in firmware, reflash, and the phone can keep serving the old one, which produces failures that survive reinstalling your app and disappear when someone forgets the device in system settings.
The method everyone reaches for is BluetoothGatt.refresh(), and it is worth knowing exactly what you are reaching for. In the AOSP source it carries an @hide marker and an @UnsupportedAppUsage annotation, described in its own comment as clearing the internal cache and forcing a refresh of the services from the remote device. It is not public API and never has been.
That annotation carries no maxTargetSdk argument, which places it on the unsupported list rather than the blocked one: usable today, explicitly subject to change, and expected to be conditionally blocked in future releases. The failure mode if it ever moves matters for your code. A blocked member does not return false, it makes getMethod throw NoSuchMethodException, so a reflection wrapper that only handles the return value will crash rather than degrade.
One clarification, because the two get conflated: this is a platform restriction enforced at runtime, not a Play Store publishing policy. Nothing rejects your build for it. The platform simply stops answering one day.
The supported mechanism is the Service Changed characteristic, which is how a bonded peripheral tells the central its database moved. That is firmware work, not app work, and it is the fix worth paying for: implement Service Changed properly and bond the device, and the cache stops being your problem. Getting that side right, including what happens when the GATT table moves under a live connection, is the same discipline as OTA updates that survive a dropped connection. Clearing it by unbonding does work, but I would treat that as field knowledge rather than documented behaviour, and it is not something you can ask a user to do twice.
What changed on recent Android versions
Three releases matter, and one of them matters by not appearing:
- Android 14 requests an ATT MTU of 517 when the first GATT client calls requestMtu, and disregards every later request on that connection. Peripherals that mishandle a 517 byte MTU drop the link, and you see the consequence as a failed connection rather than as an MTU problem. Google's own mitigation is to respond with the smaller of 517 and what the peripheral supports, and to cap writes accordingly.
- Android 15 changed nothing here. Its behaviour changes page lists no Bluetooth or GATT changes at all, which is worth knowing given how many recent articles blame it for BLE regressions.
- Android 16 stopped silently re-pairing after bond loss. Previously the system would remove the bond and start pairing again; now it disconnects, keeps the local bond information, and shows the user a dialog. A peripheral that regenerates its keys, which is what a factory reset or some firmware reflashes do, used to recover on its own and now produces repeated failures until the user re-pairs.
That last one is the most likely source of a new 133 in an app that has not changed. Sources: the behaviour change pages for Android 14, Android 15 and Android 16.
Numbers that get repeated and are wrong
This corner of Android has an unusual amount of folklore, most of it copied forward for a decade without anyone rechecking the source:
- "Android supports seven simultaneous connections." That number is not in current AOSP. What is there is GATT_MAX_PHY_CHANNEL at 16, a floor of 8 with a comment citing the compatibility definition document, and a note that the runtime maximum comes from a system property. It is device dependent, and the honest answer is to measure it on your target hardware.
- "The scan throttle is five calls per thirty seconds." That is the widely reported default, but current AOSP reads the quota and its window at runtime rather than hardcoding them. Design for a throttle you cannot predict, not for the number five. The permission model governing those scans changed in Android 12.
- "133 means the connection timed out." Android has 147 for that and uses it.
- "You must call connectGatt on the main thread." The real constraint is the Handler and the binder thread, described above. An app that follows the folklore and still re-enters the stack from a callback will keep failing.
Diagnostic order for a status 133
- Time it. Near thirty seconds with autoConnect=false is the direct connect timer expiring, which means the peripheral was not reachable. Under two seconds is your app or the stack's state.
- Watch clientIf in logcat across reconnects. Climbing means a leak, and thirty is all you get.
- Check the teardown ordering. disconnect(), wait for the callback, close(), drop the reference. Many fast 133s end here.
- Check what thread you are calling from. Not whether it is main, but whether it is the stack's own callback delivering into itself.
- Try the same peripheral from nRF Connect. If it fails there too, stop debugging the app; the problem is the firmware or the radio environment.
- Try a second phone from a different manufacturer. Vendors modify this stack, and a bug that reproduces on one device and not another is a different investigation from one that reproduces everywhere.
- Check bond state and the service cache. If it started after a firmware reflash, or after a device moved to Android 16, suspect stale bonding before anything in your code.
- Then retry with backoff. Some residue is genuinely unexplainable, and a close plus a delayed reconnect is the accepted answer to it.
The status code tells you that a connection attempt ended. Everything useful is in what happened before it.
Neighbouring failures are worth knowing apart from this one, because the symptoms overlap and the fixes do not. A scan that throws instead of returning results is usually the Android 12 permission split, and writes that vanish silently on iOS have causes of their own. An Android BLE app that connects on your desk and fails in the field is the kind of work I take on; a logcat trace and the peripheral's connection parameters are usually enough to tell which of these you have.