BLE Fundamentals — Connection Lifecycle and Platform Quirks
← All Articles

BLE Fundamentals — Connection Lifecycle and Platform Quirks

A complete tour of how a Bluetooth Low Energy connection actually happens — every step, every millisecond, and every place where Android and iOS quietly behave differently.

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:

IntervalDiscovery latencyPower costUse case
20–100 msNear-instantHighForeground tap-to-pay
100–500 ms~1 sMediumActive beacons
1–10 sSeveral secondsLowSensors / 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:

RSSIApproximate distanceInterpretation
−30 dBm≈ 15 cmTouch (tap-to-pay)
−55 dBm≈ 0.5–1 mWithin arm's reach
−70 dBm≈ 3–5 mSame 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.

PlatformDefault MTUNegotiableHow
Android23 BUp to 517 BExplicit requestMtu()
iOS185 BUp to 185 BAutomatic 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:

DeviceConnectGATT DiscoveryTotal to ready
Pixel 7 (Android 14)~80 ms1200–2500 ms1.5–3 s
Galaxy S21 (Android 13)~120 ms800–1800 ms1–2 s
iPhone 14 (iOS 17)~60 ms400–800 ms0.5–1 s
iPhone 12 (iOS 16)~50 ms500–1000 ms0.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:

ModeInitiated byDirectionWhen to use
ReadCentralPeripheral → CentralOne-shot value pulls
WriteCentralCentral → PeripheralSending commands
NotifyPeripheral (after subscription)Peripheral → CentralStreaming 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 declared neverForLocation to 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 after connect() — 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 classic BluetoothGatt.
  • 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_CONNECTED immediately and then STATE_DISCONNECTED a 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

  1. GATT discovery is your bottleneck. Everything else combined is usually under 300 ms.
  2. iOS is roughly 2× faster end-to-end because it caches GATT and has a more mature radio stack.
  3. Android requires a post-connect delay (~300 ms) on most pre-14 devices; without it you will see random GATT_ERROR 133s.
  4. Always design for MTU = 20. A negotiated MTU is a performance optimization, not a correctness assumption.
  5. 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.
  6. 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.