In this article:
- How BLE roles, advertising, and scanning actually fit together
- What happens during connect, MTU and GATT discovery — and why GATT is your bottleneck
- How a fragmented BLE packet protocol survives a 23-byte default MTU
- The real Android and iOS differences that make or break a production implementation
- A measured timeline of an offline tap-to-pay transaction
This article opens a three-part series based on a real production BLE plugin we built for offline contactless payments — a Flutter implementation where a phone acts as either the terminal (peripheral) or the customer (central), with no internet in the loop. Part 2 covers the secure channel built on top of BLE; Part 3 covers per-packet encryption and the defenses that keep the channel safe.
Roles: Central and Peripheral
BLE is asymmetric. Every connection has exactly one peripheral (the device being discovered) and one central (the device doing the discovering and connecting). Forgetting which side does what is the most common source of confusion when you start.
┌────────────────────────────────┐ ┌────────────────────────────────┐ │ PERIPHERAL (GATT Server) │ │ CENTRAL (GATT Client) │ │ │ │ │ │ • Advertises in the air │ │ • Scans for advertisements │ │ • Hosts services & chars │ │ • Initiates the connection │ │ • Sends notifications │ │ • Reads / writes / subscribes │ │ • In our case: the TERMINAL │ │ • In our case: the CUSTOMER │ └────────────────────────────────┘ └────────────────────────────────┘
A peripheral is identified by a Service UUID — a 128-bit identifier that tells the central "I am the kind of device you are looking for." Our implementation publishes a single primary service with one bidirectional characteristic:
mixin ConfigBle {
static const serviceUuid = 'e2c56db5-dffb-48d2-b060-d0f5a71096e0';
static const characteristicUuid = 'a3b1c2d4-e5f6-7890-ab12-cd34ef56ab78';
}
Central writes commands to that characteristic; peripheral pushes responses back via notifications on the same characteristic. One service, one characteristic, two directions.
Advertising
An advertising packet is a tiny broadcast — typically 31 bytes of payload (37 with header), repeated at a configured interval. It carries the device name, the primary Service UUID, optional manufacturer data, and the TX power level used to estimate distance.
The advertising interval is the single most important parameter you control here:
| Interval | Discovery latency | Power cost | Use case |
|---|---|---|---|
| 20–100 ms | Near-instant | High | Foreground tap-to-pay |
| 100–500 ms | ~1 s | Medium | Active beacons |
| 1–10 s | Several seconds | Low | Sensors / battery devices |
For a payment terminal that must respond to a tap in under a second, we advertise aggressively (≈100 ms) only while a transaction is open. Outside that window the radio sleeps.
An advertising packet is not encrypted and not authenticated. Anything you put in it — name, manufacturer ID, even a "session token" — is publicly visible in the air.
Scanning
Scanning is the opposite of advertising: the central listens to the air and reports anything matching a filter. Filters are critical — both for finding the right device, and for staying within OS-imposed scan budgets:
- Service UUID filter — primary way to find your device.
- Local name — sometimes randomized to prevent collisions.
- RSSI threshold — only act on peripherals that are physically close.
RSSI (received signal strength) is a noisy logarithmic value, but it is the only proximity signal BLE gives you:
| RSSI | Approximate distance | Interpretation |
|---|---|---|
| −30 dBm | ≈ 15 cm | Touch (tap-to-pay) |
| −55 dBm | ≈ 0.5–1 m | Within arm's reach |
| −70 dBm | ≈ 3–5 m | Same room |
| −90 dBm | ≈ 10 m+ | Edge of usable range |
Filtering by RSSI > −55 dBm is a cheap, surprisingly effective way to enforce a "phone must be near the terminal" UX without writing any custom proximity logic.
Connection
Once the central decides to connect, it sends a connection request and the radio negotiates a connection interval (the heartbeat at which the two devices exchange packets). On most modern hardware the link is established in 50–200 ms.
This is also where the first big platform divergence shows up. On Android, connect() returns before the BLE stack is actually ready for follow-up operations like service discovery. Skipping a small post-connect delay leads to silent GATT_ERROR 133s, especially on devices below Android 14:
await _central.connect(peripheral);
if (Platform.isAndroid) {
// Without this, GATT discovery can return partial data or throw 133.
await Future.delayed(const Duration(milliseconds: 300));
}
On iOS this delay is not necessary — Core Bluetooth blocks until the link is fully ready before resolving the connection callback.
MTU Negotiation
The default ATT MTU is 23 bytes, which after the 3-byte ATT header leaves you with 20 bytes of usable payload. That is wildly insufficient for anything beyond a sensor reading.
| Platform | Default MTU | Negotiable | How |
|---|---|---|---|
| Android | 23 B | Up to 517 B | Explicit requestMtu() |
| iOS | 185 B | Up to 185 B | Automatic on connect |
iOS quietly negotiates the maximum it supports and does not let you tune it. Android requires you to ask, and the value the peer agrees to is the smaller of the two sides' requested values:
if (Platform.isAndroid) {
try {
final mtu = await _central.requestMTU(peripheral, mtu: 247);
log('Negotiated MTU: $mtu');
} catch (_) {
// Some Android stacks reject; fall back to chunking on 20 bytes.
}
}
Whatever MTU you end up with, your application protocol must still be prepared to chunk anything larger than the negotiated value. We always design for a 20-byte worst case and let the negotiated MTU be a performance optimization, not a correctness assumption.
GATT Discovery — the Bottleneck
GATT discovery is where the central asks the peripheral "what services and characteristics do you expose?" It is the slowest, most variable step in the entire connection lifecycle.
Real measurements from production tests:
| Device | Connect | GATT Discovery | Total to ready |
|---|---|---|---|
| Pixel 7 (Android 14) | ~80 ms | 1200–2500 ms | 1.5–3 s |
| Galaxy S21 (Android 13) | ~120 ms | 800–1800 ms | 1–2 s |
| iPhone 14 (iOS 17) | ~60 ms | 400–800 ms | 0.5–1 s |
| iPhone 12 (iOS 16) | ~50 ms | 500–1000 ms | 0.6–1.1 s |
iOS is roughly 2× faster, for two reasons that you cannot opt out of:
- Service caching. Once Core Bluetooth has discovered a peripheral's GATT table, it caches it. Subsequent connections skip discovery entirely unless the peripheral signals a change via Service Changed indications.
- Stack maturity. Core Bluetooth has had years of optimization for radio scheduling and ATT round-trips. The Android BLE stack still varies dramatically per-vendor — Samsung, Pixel and Xiaomi all behave differently for the same code.
If your UX has a "tap and wait" moment, it is GATT discovery you are waiting on. Everything else combined is usually under 300 ms.
Subscriptions and Notifications
BLE gives you three I/O modes on a characteristic:
| Mode | Initiated by | Direction | When to use |
|---|---|---|---|
| Read | Central | Peripheral → Central | One-shot value pulls |
| Write | Central | Central → Peripheral | Sending commands |
| Notify | Peripheral (after subscription) | Peripheral → Central | Streaming responses |
For a payment flow we use Write for client→terminal traffic and Notify for terminal→client responses. Subscribing takes 20–100 ms — almost free compared to GATT discovery — and is the last setup step before real data starts flowing.
Packet Protocol & Chunking
Because the worst-case usable payload is 20 bytes, every higher-level message must survive being split into many BLE packets and reassembled at the other end. We use a fixed 4-byte header in front of each logical message:
┌──────────┬──────────┬───────────────┬──────────────┐ │ Version │ Type │ Payload Length│ Payload │ │ 1 byte │ 1 byte │ 2 bytes BE │ N bytes │ │ (0x01) │ (0x10..) │ (uint16) │ │ └──────────┴──────────┴───────────────┴──────────────┘
The peripheral side splits outgoing payloads into fixed-size chunks before pushing notifications:
Future<void> _sendNotification(
Central central,
GATTCharacteristic char,
Uint8List data,
) async {
const chunkSize = 20;
for (var i = 0; i < data.length; i += chunkSize) {
final end = (i + chunkSize).clamp(0, data.length);
await _peripheral.notifyCharacteristic(
central, char, value: data.sublist(i, end),
);
}
}
The central reassembles by concatenating chunks into a buffer until either the declared length matches, or a short timeout (~120 ms) elapses without a header — meaning the buffered bytes are already an encrypted body whose plaintext header was consumed during decryption. This dual-mode reassembly keeps the protocol resilient to the encryption layer added in Part 3.
Android Deep Dive
Android exposes BLE through BluetoothLeScanner, BluetoothGatt, and BluetoothGattCallback. The API has accumulated a long list of platform-specific behaviours that anyone shipping production BLE on Android must know about.
Permissions changed in Android 12
Before Android 12, BLE scanning required ACCESS_FINE_LOCATION — because BLE beacons can be used to triangulate a device. Android 12 split this into three new runtime permissions:
BLUETOOTH_SCAN— required to scan; can be declaredneverForLocationto avoid the location prompt.BLUETOOTH_CONNECT— required to connect, read, write, subscribe.BLUETOOTH_ADVERTISE— required to act as a peripheral.
Production apps need both code paths: legacy FINE_LOCATION on API ≤ 30 and the three split permissions on API ≥ 31. If you forget the neverForLocation flag, users get a location prompt that scares them off.
The 133 problem
Status code 133 (GATT_ERROR) is Android's "something went wrong, good luck" code. Common causes:
- Calling
discoverServices()too soon afterconnect()— fixed by the 300 ms delay above. - The system's GATT cache holding a stale service definition. Fixed via the unofficial
refreshDeviceCache()reflection trick on classicBluetoothGatt. - Concurrent BLE operations — Android serializes GATT calls per device, but does not always queue them for you. A characteristic write started before the previous one completed silently fails.
Callback threading
Every BluetoothGattCallback method runs on a binder thread, not the main thread. Posting back to the UI is your responsibility, and any state you mutate from these callbacks must be either thread-safe or marshalled through a single-threaded queue.
Background scanning is heavily throttled
Since Android 7 (Nougat), background scans are throttled to one per 30 minutes per app. Since Android 8, you must use a foreground service or a PendingIntent-based scan to remain reliably discoverable while the screen is off. There is no real "background BLE" on Android the way there is on iOS — you simulate it with a service the user can see.
Vendor variation
The single biggest source of mystery bugs. Samsung, Xiaomi, OnePlus, and Pixel devices all ship slightly different BLE stacks:
- Some Samsung models drop notifications when the screen turns off, even with a foreground service.
- Some Xiaomi devices require the app to be whitelisted from battery optimization or the GATT connection silently dies after a minute.
- Some older OnePlus models report
STATE_CONNECTEDimmediately and thenSTATE_DISCONNECTEDa second later when the link actually fails to establish — the connection handler must be idempotent.
iOS Deep Dive
Core Bluetooth is the iOS API surface. It is more opinionated than Android, which means fewer footguns — but also less control.
Permissions are simpler but less flexible
You declare a single NSBluetoothAlwaysUsageDescription string in Info.plist. There is no separation between scan, connect, and advertise. The first time the app touches Core Bluetooth, the user sees one prompt that grants everything.
Service caching is automatic and aggressive
The first connection to a peripheral triggers full GATT discovery; subsequent connections to the same peripheral (matched by identifier) skip discovery and reuse the cached service tree. This is why the second iOS connection in our measurements is dramatically faster than the first.
The cache is invalidated only when the peripheral sends a Service Changed indication. If you change your peripheral's GATT during development, central devices will keep using stale data until the peripheral explicitly notifies them — or until iOS decides to clear its cache, which is opaque and unpredictable.
Background mode constraints
iOS lets a central keep working in the background by enabling the bluetooth-central background mode. Scanning continues but with restrictions: only background scans for explicit Service UUIDs are allowed, no wildcard discovery, and matches are coalesced by the system to save power.
For peripherals the rules are stricter. Background advertising is allowed, but the local name and most data fields are stripped from the advertising packet. The Service UUID moves into a special "overflow" area that only other iOS devices know how to scan. Cross-platform background advertising on iOS is effectively impossible without a workaround like Apple Watch / iBeacon-style frames.
State preservation and restoration
If iOS terminates your app while a Core Bluetooth operation is in progress, the system can re-launch your app in the background when something happens — a peripheral comes into range, a connection completes — provided you opted into state preservation by passing CBCentralManagerOptionRestoreIdentifierKey when creating your manager. This is the closest BLE gets to true background work on either platform.
No connection-state events for peripherals
When you act as a peripheral on iOS, you do not receive a callback when a central disconnects. You only learn about it indirectly — by attempting to send a notification and seeing it fail, or by tracking which centrals are subscribed to a characteristic. We work around this by sending a small heartbeat from the central side and treating its absence as a disconnect.
Full Connection Timeline
Putting it all together, here is the measured timeline for an offline payment from "scan started" to "payment result received," with platform-specific values where they differ:
t=0 ─── Scan started ─────────────────────────────
t=~200 ─── Terminal found (RSSI > −55 dBm) ──────────
t=~250 ─── BLE connect ────────────────────────────────
t=~550 ─── Android post-connect delay (300 ms) ─────── iOS: skip
t=~600 ─── MTU request (~30 ms) ────────────────────── iOS: automatic
t=~700 ─── Android settle (100 ms) ────────────────── iOS: skip
t=~800 ─── GATT discovery start ─────────────────────
t=~2300 ─── GATT discovery complete (Android) ────────
─── GATT discovery complete (iOS, ~1200 ms) ──
t=~2350 ─── Notify subscription (~50 ms) ─────────────
t=~2400 ─── DH handshake start (Part 2) ──────────────
t=~2600 ─── Encrypted channel ready ──────────────────
t=~2650 ─── Send encrypted payment token ─────────────
t=~2800 ─── Payment result received ──────────────────
t=~3000 ─── Cleanup & disconnect ──────────────────────
≈ 3 seconds end-to-end on Android, ≈ 1.5 seconds on iOS. Once you have established the channel, every subsequent transaction in the same session is sub-second.
Takeaways
- GATT discovery is your bottleneck. Everything else combined is usually under 300 ms.
- iOS is roughly 2× faster end-to-end because it caches GATT and has a more mature radio stack.
- Android requires a post-connect delay (~300 ms) on most pre-14 devices; without it you will see random
GATT_ERROR133s. - Always design for MTU = 20. A negotiated MTU is a performance optimization, not a correctness assumption.
- Background BLE on iOS is structured; on Android it requires a foreground service. Do not promise users "always-on" tap-to-pay without a battery story.
- Vendor variation on Android is real. Test on at least Samsung, Pixel, and Xiaomi before shipping.
BLE itself is just the transport. The interesting part — and the part where you stop trusting the radio — starts in Part 2: Building a Secure Channel over BLE.