Benchmark Results
All benchmarks were performed against a nRF54L15 DK peripheral running bleRPC firmware with E2E encryption enabled (AES-128-GCM). MTU was negotiated to 247 bytes. Every central negotiated a 15ms connection interval.
Throughput Comparison
Python (macOS)
CI: 15ms
iOS (iPhone 16)
CI: 15ms
Android (Pixel 5)
CI: 15ms
Web (Chrome, macOS)
CI: 15ms
Detailed Results by Platform
Python Central (macOS, bleak)
Connection interval: 15ms (macOS negotiation) — test code
| Benchmark | Result | Details |
|---|---|---|
| flash_read_throughput | 56.6 KB/s | 10x 8192 bytes, 141.3 ms/call |
| flash_read_overhead | 30.1 ms/call | 1 byte x 20 calls |
| echo_roundtrip | 30.0 ms/call | 50 calls |
| data_write_throughput | 6.5 KB/s | 200 bytes x 20 calls, 30.1 ms/call |
| counter_stream (P→C) | 3.3 ms/item | 20 items in 66 ms |
| counter_upload (C→P) | 2.9 ms/item | 20 items in 59 ms |
| Functional Test | Result | Details |
|---|---|---|
| echo_basic | PASS | |
| echo_empty | PASS | |
| echo_max_length | PASS | 256-char string |
| flash_read_basic | PASS | 16 bytes |
| flash_read_8kb | PASS | 8192 bytes |
| data_write_basic | PASS | 1024 bytes |
| data_write_8kb | PASS | 8192 bytes |
| multi_container_echo | PASS | 250-char string |
| counter_stream | PASS | 5 items |
| counter_stream_large | PASS | 20 items |
| counter_upload | PASS | 5 items |
| counter_upload_large | PASS | 20 items |
iOS Central (iPhone 16, CoreBluetooth)
Connection interval: 15ms (iOS negotiation) — test code
| Benchmark | Result | Details |
|---|---|---|
| flash_read_throughput | 55.5 KB/s | 10x 8192 bytes, 144.1 ms/call |
| flash_read_overhead | 30.0 ms/call | 1 byte x 20 calls |
| echo_roundtrip | 30.6 ms/call | 50 calls |
| data_write_throughput | 6.4 KB/s | 200 bytes x 20 calls, 30.6 ms/call |
| counter_stream (P→C) | 3.1 ms/item | 20 items in 62 ms |
| counter_upload (C→P) | 3.8 ms/item | 20 items in 75 ms |
| Functional Test | Result | Details |
|---|---|---|
| echo_basic | PASS | |
| echo_empty | PASS | |
| flash_read_basic | PASS | 64 bytes |
| flash_read_8kb | PASS | 8192 bytes |
| data_write | PASS | 64 bytes |
| counter_stream | PASS | 5 items |
| counter_upload | PASS | 5 items |
Android Central (Pixel 5)
Connection interval: 15ms (Android negotiation) — test code
| Benchmark | Result | Details |
|---|---|---|
| flash_read_throughput | 83.5 KB/s | 10x 8192 bytes, 95.8 ms/call |
| flash_read_overhead | 38.4 ms/call | 1 byte x 20 calls |
| echo_roundtrip | 38.2 ms/call | 50 calls |
| data_write_throughput | 4.8 KB/s | 200 bytes x 20 calls, 40.7 ms/call |
| counter_stream (P→C) | 2.8 ms/item | 20 items in 55 ms |
| counter_upload (C→P) | 5.9 ms/item | 20 items in 118 ms |
| Functional Test | Result | Details |
|---|---|---|
| echo_basic | PASS | |
| echo_empty | PASS | |
| flash_read_basic | PASS | 64 bytes |
| flash_read_8kb | PASS | 8192 bytes |
| data_write | PASS | 64 bytes |
| counter_stream | PASS | 5 items |
| counter_upload | PASS | 5 items |
Web Central (Chrome, macOS, Web Bluetooth)
Connection interval: 15ms (macOS negotiation) — test code
| Benchmark | Result | Details |
|---|---|---|
| flash_read_throughput | 57.4 KB/s | 10x 8192 bytes, 139.3 ms/call |
| flash_read_overhead | 30.0 ms/call | 1 byte x 20 calls |
| echo_roundtrip | 30.0 ms/call | 50 calls |
| data_write_throughput | 6.5 KB/s | 200 bytes x 20 calls, 30.0 ms/call |
| counter_stream (P→C) | 3.1 ms/item | 20 items in 61 ms |
| counter_upload (C→P) | 2.8 ms/item | 20 items in 56 ms |
Measured with the central_web demo (Chrome, encrypted, MTU 247). Results match the Python (macOS) central closely — the same transport-agnostic protocol layer (@blerpc/protocol-ts) runs unchanged in the browser.
Performance Notes
Connection Interval Impact
The connection interval (CI) is the primary factor in round-trip latency: a request–response cycle costs two connection events (the central writes the request, the peripheral notifies the response). The firmware pins its preferred interval to 15ms (PREF_MIN_INT = PREF_MAX_INT = 12) and every central tested accepts it:
- macOS (bleak): CI = 15ms → 30.0ms round-trip
- iOS (iPhone 16): CI = 15ms → 30.6ms round-trip
- Android (Pixel 5): CI = 15ms → 38.2ms round-trip, but highest throughput due to more packets per connection event
- Web (Chrome, macOS): CI = 15ms → 30.0ms round-trip (same transport and protocol layer as Python)
Asking for a range is what costs you. With the earlier 15–30ms window (PREF_MAX_INT=24) every central picked 30ms — the power-efficient end — and the round-trip doubled to ~60ms. That 30ms was never an OS floor; it was the peripheral’s own request. Two things that did not get in the way: Android needs no requestConnectionPriority(CONNECTION_PRIORITY_HIGH), because the peripheral’s parameter update request is honoured regardless of the app’s connection priority, and the iPhone did not show the 15ms→30ms scale-up that Apple’s QA1931 warns some devices apply. The trade-off is power draw.
Note for anyone reproducing these numbers: the peripheral only sends its parameter update request CONFIG_BT_CONN_PARAM_UPDATE_TIMEOUT (5s) after connecting, and centrals start elsewhere — macOS and iOS at 30ms, the Pixel 5 at 45ms. Every benchmark above waits 5.5s after connecting so that it measures the negotiated interval rather than the central’s initial one.
Encryption Overhead
E2E encryption adds 20 bytes per transaction (4-byte counter + 16-byte AES-GCM tag). For large payloads (8 KB), this overhead is negligible (<0.3%). The AES-128-GCM encryption/decryption itself is hardware-accelerated on modern mobile devices and takes <1ms even for 8 KB payloads.
Throughput Optimizations
- nanopb FT_CALLBACK: Streams flash data directly into protobuf encoding, saving 4KB static RAM
- Zero-copy response: Protobuf response is encoded directly at the container header offset
- No inter-packet delay: Containers are sent back-to-back
- Max 8KB per flash read: Optimal balance between latency and throughput