BLE Throughput Optimization on Android: Real Benchmarks Across Chipsets

The Bluetooth Low Energy specification promises theoretical throughput numbers that no real-world application ever achieves. Marketing materials cite 2 Mbps for BLE 5.0, but the actual application-layer throughput you can get on Android typically ranges from 10 KB/s to 140 KB/s, depending on your configuration, the Android device, and the peripheral chipset. That is a massive range, and the difference between the low end and the high end can determine whether your product works or fails.

For neurotechnology devices streaming biosignal data, firmware updates, or sensor fusion payloads, throughput is not an abstract number. It defines how many EEG channels you can stream simultaneously, how fast a firmware update completes, and whether your app feels responsive or sluggish. At DEVSFLOW in Toronto, we have benchmarked BLE throughput across dozens of Android devices for clients including RE-AK Technologies and CLEIO. This article presents our real-world measurements and the optimization techniques that make the biggest difference.

1. Theoretical vs. Real-World BLE Throughput

To understand where throughput goes, you need to understand the BLE protocol stack's overhead at each layer.

BLE 4.2 throughput breakdown

With BLE 4.2 and Data Length Extension (DLE), a single connection event can transfer up to 251 bytes per PDU (Protocol Data Unit). After subtracting the L2CAP header (4 bytes) and ATT header (3 bytes for notifications), you get 244 bytes of application payload per packet. At a 7.5ms connection interval with 6 packets per connection event, the theoretical maximum is:

In practice, you rarely get 6 packets per connection event. Most Android devices allow 4-6 packets per event on BLE 4.2, and the actual connection interval is often 15ms or 30ms rather than 7.5ms. Real-world BLE 4.2 throughput on Android typically lands between 20 KB/s and 80 KB/s.

BLE 5.0 with 2M PHY

BLE 5.0 introduced the 2M PHY, which doubles the radio symbol rate. Combined with DLE, the theoretical maximum rises to approximately 390 KB/s. In practice, 2M PHY on Android delivers 60-140 KB/s of application throughput, depending on the device and connection parameters.

Where does throughput go?

2. MTU Size Impact on Throughput

MTU (Maximum Transmission Unit) size is the single most impactful throughput optimization on Android. The default BLE MTU is 23 bytes, which gives you only 20 bytes of ATT payload per packet (3 bytes for the ATT header). With Data Length Extension, you can request up to 517 bytes, giving you 512 bytes of ATT payload.

Requesting MTU on Android

override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
    if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
        // Request maximum MTU immediately after connection
        gatt.requestMtu(517)
    }
}

override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
    if (status == BluetoothGatt.GATT_SUCCESS) {
        val attPayload = mtu - 3 // Subtract ATT header
        Log.d(TAG, "MTU negotiated: $mtu, usable payload: $attPayload bytes")
        // Now proceed with service discovery
        gatt.discoverServices()
    }
}

Real MTU negotiation results by device

We tested MTU negotiation requesting 517 bytes against an nRF52840 peripheral (which supports 247 bytes max) across 15 Android devices. Here are representative results:

The good news: virtually all modern Android devices (2020 and later) support 247-byte MTU without issues. The limitation is typically on the peripheral side. If your peripheral only supports 23-byte MTU, upgrading the peripheral firmware to support DLE and larger MTU is the single best thing you can do for throughput.

Throughput impact of MTU size

We measured notification throughput (peripheral sending continuous data to Android) with different MTU sizes on a Pixel 8 Pro with a 15ms connection interval:

Going from the default 23-byte MTU to 247 bytes gives you a 7x throughput improvement. This is the easiest and most impactful optimization available.

3. Connection Interval Tuning

The connection interval determines how often the central (Android) and peripheral exchange data. Shorter intervals mean more frequent data exchanges and higher throughput, but also higher power consumption. BLE supports connection intervals from 7.5ms to 4 seconds.

Requesting connection priority on Android

Android does not expose direct connection interval control. Instead, you request a connection priority, and the system maps it to a connection interval range:

// Request high priority (7.5ms - 15ms connection interval)
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)

// Request balanced (30ms - 50ms, the default)
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED)

// Request low power (100ms - 500ms)
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_LOW_POWER)

What Android actually negotiates

The actual connection interval after requesting CONNECTION_PRIORITY_HIGH varies significantly by device. We measured using HCI snoop logs:

The difference between 7.5ms and 22.5ms is a 3x throughput reduction, all else being equal. Budget Samsung devices consistently negotiate longer connection intervals, which is important to know if your target market includes lower-end Android devices.

Throughput vs. connection interval

Measured on a Pixel 8 Pro with MTU 247 and 1M PHY:

Connection interval and MTU are multiplicative. If you are stuck at the default 23-byte MTU and 30ms connection interval, you get roughly 3 KB/s. With 247-byte MTU and 7.5ms interval, you get over 78 KB/s. That is a 26x difference from just two optimizations.

4. PHY Selection: 1M vs. 2M vs. Coded

BLE 5.0 introduced three PHY (Physical Layer) options. The PHY determines the radio symbol rate and directly affects throughput and range.

Requesting 2M PHY on Android

// Request 2M PHY after connection
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    gatt.setPreferredPhy(
        BluetoothDevice.PHY_LE_2M_MASK,  // TX PHY preference
        BluetoothDevice.PHY_LE_2M_MASK,  // RX PHY preference
        BluetoothDevice.PHY_OPTION_NO_PREFERRED  // Coded PHY options (unused for 2M)
    )
}

override fun onPhyUpdate(gatt: BluetoothGatt, txPhy: Int, rxPhy: Int, status: Int) {
    if (status == BluetoothGatt.GATT_SUCCESS) {
        Log.d(TAG, "PHY updated - TX: ${phyToString(txPhy)}, RX: ${phyToString(rxPhy)}")
    }
}

private fun phyToString(phy: Int): String = when (phy) {
    BluetoothDevice.PHY_LE_1M -> "1M"
    BluetoothDevice.PHY_LE_2M -> "2M"
    BluetoothDevice.PHY_LE_CODED -> "Coded"
    else -> "Unknown ($phy)"
}

2M PHY throughput benchmarks

We measured throughput with MTU 247, CONNECTION_PRIORITY_HIGH, and both 1M and 2M PHY across several devices. All tests used an nRF52840 peripheral:

2M PHY consistently delivers 46-70% higher throughput. The improvement varies because the connection interval (which differs by device) becomes the bottleneck at higher radio speeds. On devices with a 7.5ms interval, 2M PHY lets you fit more packets per connection event. On devices with a 22.5ms interval, the longer event time already accommodates more packets at 1M, so the 2M improvement is proportionally less dramatic.

5. Data Length Extension (DLE)

Data Length Extension, introduced in BLE 4.2, allows link-layer PDUs to carry up to 251 bytes instead of the original 27 bytes. DLE works at the link layer, below ATT and L2CAP, and is a prerequisite for getting full benefit from larger MTU sizes.

How DLE interacts with MTU

Without DLE, even if you negotiate a 247-byte MTU, the link layer fragments each ATT PDU into multiple 27-byte link-layer packets. The controller handles this transparently, but it introduces significant overhead because each 27-byte fragment has its own link-layer header and requires its own T_IFS gap.

With DLE enabled, a single 244-byte ATT notification fits in one link-layer PDU (244 + 4 L2CAP header + 3 ATT header = 251 bytes, exactly the DLE maximum). This eliminates the fragmentation overhead entirely.

DLE on Android

Android has supported DLE since Android 5.1, but the implementation is automatic and not directly controllable by apps. When you negotiate a larger MTU, the Android Bluetooth stack automatically requests DLE from the peripheral. You can verify DLE is active by checking HCI snoop logs for the LL_LENGTH_REQ and LL_LENGTH_RSP packets.

In our testing, DLE is reliably enabled on all Android devices from 2018 and later. Older devices (pre-2017) sometimes fail to negotiate DLE even though they claim BLE 4.2 support. If you need to support these devices, test MTU negotiation and verify actual throughput rather than relying on spec compliance.

Throughput with and without DLE

We tested by using a peripheral that supports configurable DLE. With MTU 247 and a 15ms connection interval on a Pixel 8 Pro:

That is a 2.25x improvement just from DLE, with no other changes. If you are seeing unexpectedly low throughput despite a large MTU, check your HCI snoop logs to confirm DLE is actually active.

6. Benchmarks Across Chipsets: Qualcomm vs. MediaTek vs. Samsung Exynos

The Bluetooth chipset in the Android device is arguably the most important variable in BLE throughput, and it is the one you have the least control over. We ran standardized benchmarks across three chipset families using identical test conditions: MTU 247, CONNECTION_PRIORITY_HIGH, 2M PHY, continuous notifications from an nRF52840 peripheral at 0.5 meter range.

Qualcomm (Snapdragon + WCN Bluetooth)

Qualcomm chipsets deliver consistent, predictable performance. The connection interval they negotiate in high-priority mode varies slightly by device but is generally in the 7.5-15ms range. Qualcomm is our recommended chipset family for BLE-intensive applications.

Google Tensor (Custom + Exynos-based Bluetooth)

Pixel devices with Tensor chips deliver the best BLE throughput of any Android devices we have tested. They consistently negotiate the shortest connection intervals and allow the most packets per connection event. If you are optimizing a product and need a reference device, the Pixel 8 Pro is our recommendation.

Samsung Exynos

Exynos devices show the widest performance spread. Flagship Exynos chips perform comparably to Qualcomm, but budget Exynos devices negotiate significantly longer connection intervals and achieve lower throughput. The Galaxy A14, one of the best-selling Android phones in Canada, delivers less than half the throughput of a Pixel 8 Pro under identical conditions.

MediaTek (Dimensity)

MediaTek's flagship Dimensity chips have improved dramatically and now rival Qualcomm and Tensor. Budget MediaTek chips are respectable, generally outperforming budget Exynos. One caveat: some MediaTek devices have quirks with connection parameter updates, occasionally ignoring the peripheral's request and sticking with a longer interval even after CONNECTION_PRIORITY_HIGH.

7. Measuring Actual Throughput in Your App

Published benchmarks (including ours) are useful for setting expectations, but you must measure throughput in your specific app with your specific peripheral. BLE throughput is highly context-dependent, and small differences in timing, connection parameters, or peripheral firmware can shift results significantly.

Building a throughput measurement utility

class BleThroughputMeter {

    private data class Sample(val timestamp: Long, val bytes: Long)

    private val samples = ArrayDeque<Sample>(100)
    private var totalBytes: Long = 0
    private var startTime: Long = 0

    fun start() {
        samples.clear()
        totalBytes = 0
        startTime = System.nanoTime()
    }

    fun recordBytes(count: Int) {
        totalBytes += count
        val now = System.nanoTime()
        samples.addLast(Sample(now, totalBytes))

        // Keep only last 100 samples for rolling average
        while (samples.size > 100) samples.removeFirst()
    }

    /** Instantaneous throughput over the last N samples, in bytes/second */
    fun instantaneousThroughput(): Double {
        if (samples.size < 2) return 0.0
        val first = samples.first()
        val last = samples.last()
        val timeDeltaNanos = last.timestamp - first.timestamp
        if (timeDeltaNanos == 0L) return 0.0
        val bytesDelta = last.bytes - first.bytes
        return bytesDelta.toDouble() / timeDeltaNanos * 1_000_000_000
    }

    /** Average throughput since start, in bytes/second */
    fun averageThroughput(): Double {
        val elapsed = System.nanoTime() - startTime
        if (elapsed == 0L) return 0.0
        return totalBytes.toDouble() / elapsed * 1_000_000_000
    }

    /** Total bytes received */
    fun totalBytesReceived(): Long = totalBytes

    fun summary(): String {
        val instantKbps = instantaneousThroughput() / 1024
        val avgKbps = averageThroughput() / 1024
        return "Throughput: %.1f KB/s (avg: %.1f KB/s), Total: %d bytes".format(
            instantKbps, avgKbps, totalBytes
        )
    }
}

Using the meter in your GATT callback

private val throughputMeter = BleThroughputMeter()

override fun onCharacteristicChanged(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    value: ByteArray
) {
    throughputMeter.recordBytes(value.size)

    // Log every 100 notifications
    if (throughputMeter.totalBytesReceived() % (244 * 100) < 244) {
        Log.d(TAG, throughputMeter.summary())
    }
}

Factors that affect your measurement

8. Optimization Checklist and Priority Order

If you are starting a new BLE project or optimizing an existing one, apply these optimizations in order of impact. Each step builds on the previous ones.

  1. Negotiate maximum MTU (impact: up to 7x): Call requestMtu(517) immediately after connection. Ensure your peripheral supports at least 247-byte MTU. This is the single most impactful change.
  2. Request high connection priority (impact: up to 3x): Call requestConnectionPriority(CONNECTION_PRIORITY_HIGH) after connection. Remember to switch back to balanced priority when high throughput is not needed, to preserve battery.
  3. Enable 2M PHY (impact: 50-70%): Call setPreferredPhy() requesting 2M PHY. Requires BLE 5.0 on both sides. Falls back gracefully to 1M if unsupported.
  4. Verify DLE is active (impact: up to 2x): Check HCI snoop logs for link-layer PDU sizes. If DLE is not negotiating, investigate the peripheral firmware.
  5. Use Write Without Response for outbound data (impact: 2-3x vs Write With Response): Write With Response waits for an ATT acknowledgment per packet, halving throughput. Use Write Without Response and implement application-level acknowledgment if needed.
  6. Minimize callback processing time (impact: variable): Keep GATT callbacks fast. Offload parsing, storage, and UI updates to background threads. A slow callback creates back-pressure that reduces effective throughput.
  7. Test on low-end devices (impact: expectation management): Your minimum throughput is defined by your worst-supported device, not your best. Test on a budget Samsung Galaxy A-series phone to understand your floor.

After applying all optimizations, you can expect the following throughput ranges for notification-based data streaming:

For context, an 8-channel EEG device sampling at 250 Hz with 24-bit resolution needs approximately 6 KB/s. Even a budget Android device has plenty of headroom for this. But a 32-channel device at 1000 Hz needs 96 KB/s, which is achievable only on flagship devices with all optimizations applied. Understanding these numbers early in your project informs hardware decisions, sampling rate choices, and compression strategies.

Need help squeezing maximum throughput out of BLE on Android for your medical or neurotech device? Talk to DEVSFLOW Neuro. We build BLE-connected mobile apps for neurotechnology and medical device companies across Canada.