Bluetooth best practices
The rules that make Bare Bluetooth code reliable across iOS, Android, macOS and Linux, from waiting for the radio and normalizing UUIDs to timeouts, flow control and teardown, with the upstream code each one comes from.
Bluetooth code that works between two laptops on a desk often fails between two phones in a pocket. Radios turn off, peers walk out of range, platforms report the same thing in different ways, and some failures never raise an event at all. The practices below come from code that has already been through that: the bare-bluetooth examples, the bare-mobile-doctor chat, and ble-swarm, the transport Holepunch builds peer-to-peer apps on. Each one says why, and where upstream does it.
For how the pieces fit together, read Bluetooth in Bare first.
Radio state
Wait for poweredOn, every time
Nothing works until the adapter reports 'poweredOn'. central.startScan() throws a BluetoothError with code NOT_POWERED_ON before then, and the other calls fail or do nothing. So start work from a stateChange listener, not from your constructor, and attach that listener as soon as you create the Central or Server.
Treat every arrival at 'poweredOn' as a fresh start, not just the first. When the user turns Bluetooth off, the operating system drops your GATT services, advertising and scans: on Android, bare-bluetooth closes the GATT server, and its services go with it. When the radio comes back, add your services and start advertising again. ble-swarm goes further: after a power cycle it builds new Central and Server objects altogether, having found the surviving ones unreliable.
Handle the states that never become 'poweredOn' too: 'unauthorized' when the user denied Bluetooth access on Apple platforms, and 'unsupported' on hardware without Bluetooth Low Energy. Show the user something instead of waiting forever.
Errors and timeouts
Always listen for error
bare-bluetooth reports almost every failure as an error event: a scan that fails, a connection that can't be made, a read that's refused. As with any event emitter, an error event nobody listens for throws, which in a Bare worklet can take the whole runtime down. Listen for error on every Central, Server and Peripheral, and on every L2CAPChannel, where a write to a peer that has gone destroys the stream with an error rather than throwing from write().
Expect errors where you'd expect disconnect
On Apple and Android, two outcomes you might expect as disconnect arrive as error instead:
- A connection attempt that fails:
errorwith codeCONNECTION_FAILED, and the device's id aserror.id. - A disconnect that carries an error, such as the link dropping:
errorwith codeDISCONNECT.
Only a clean disconnect emits disconnect. If your code waits for disconnect to reset its state, route those two codes to the same place, as bare-mobile-doctor's BLECentral does.
Set your own connection timeout
bare-bluetooth sets no deadline on central.connect(), and on Apple an attempt never times out by itself: if the peer is gone, the attempt waits until something cancels it. Start a timer when you connect, and when it fires, cancel the attempt with central.disconnect(), passing the same object you passed to connect(). bare-mobile-doctor gives up after 15 seconds and goes back to scanning; bare-bluetooth-apple's own tests give up after 5.
On Linux, cancelling a pending attempt this way doesn't work in bare-bluetooth 0.6.3-0: disconnect() expects a connected Peripheral, and throws when given the DiscoveredPeripheral the attempt started from.
Notice a peer that has vanished
When a peer walks out of range or its app is killed, you may hear nothing for a long time. On iOS, a vanished peer never produces a disconnect event at all. If you need to know promptly, send keepalives and drop a link that stays silent: ble-swarm pings every 5 seconds and closes a link after 15 seconds without traffic. On an L2CAP channel, a peer that leaves cleanly shows up as close or error on the stream on every platform, which is why bare-mobile-doctor's chat needs no extra disconnect detection.
Discovery
Match peers on a service UUID, not a name
An advertised name is unreliable: it may arrive after the first discover or not at all, and on Android startAdvertising({ name }) can only include the phone's own system Bluetooth name, not the string you pass. Advertise a service UUID and scan for it instead, as every upstream example does. Pick it from the Bluetooth base range (0000xxxx-0000-1000-8000-00805f9b34fb) when you can: it then takes 2 bytes of the advertisement instead of 16. An advertisement is small, and CoreBluetooth reports ADVERTISE_FAILED for one that doesn't fit.
Treat discovery as a stream of sightings
discover reports advertisements, not devices, and bare-bluetooth passes them on without filtering. The same device turns up again and again: on Android, by default, for every advertising packet, dozens of times a second; on Apple, several times per scan even with allowDuplicates: false. Key what you find by id, keep the latest, and drive your UI from that map, as bare-mobile-doctor does.
On Linux, BlueZ also replays devices it remembers from earlier scans. Those arrive with rssi set to null, meaning this scan hasn't actually heard them, so skip them before connecting. A device's name may also arrive in a later discover for the same id.
Stop scanning once you've found what you need
A scan keeps the radio busy and costs battery for as long as it runs, so stop it before you connect, as the upstream examples do, and don't leave one running in the background. ble-swarm runs its scans in 5-second bursts with long pauses in between once it has a link.
GATT
Normalize UUIDs before comparing them
CoreBluetooth reports UUIDs uppercase, and shortens those in the Bluetooth base range to 4 or 8 characters. Android and BlueZ report them long and lowercase. So service.uuid === SERVICE_UUID fails on at least one platform whatever form you wrote SERVICE_UUID in; the comparison in the bare-bluetooth README fails on all of them. Expand and lowercase both sides first, with the helper from the gattpong.js example.
Answer requests right away
A central waiting on a write with response, or on a read, times out if the server takes too long to answer. Call server.respondToRequest() first, then process the data, as ble-swarm does:
for (const req of requests) {
// must respond within ms or the central times out — before any parsing
if (req.responseNeeded !== false) this.server.respondToRequest(req, ok)
if (this._suspended) continue
this._onServerFrame(req.data)
}Keep values small, or move to L2CAP
A single GATT write or notification carries at most the connection's ATT MTU minus 3 bytes. Apple and Linux negotiate a larger MTU on their own; on Android, call peripheral.requestMtu(517) after connecting and wait for mtuChanged. If you have to split data across several GATT operations anyway, ble-swarm defaults to 150-byte pieces, which fit the MTU most links end up with, and raises the size once a larger MTU is negotiated. For anything larger than that, an L2CAP channel is simpler and faster.
Respect notification flow control
server.updateValue() returns false when it couldn't queue the notification. On Apple that means the transmit queue is full: hold the value, and send it again when the server emits readyToUpdate. ble-swarm also retries on a 250 ms timer, since it found that readyToUpdate can be missed. Even true only means the notification was queued, not that it arrived.
On Android, only one GATT operation can be in flight per connection. bare-bluetooth queues reads, writes, subscriptions and MTU requests for you and sends them one at a time; one the system refuses to start arrives as error with code NOT_INITIATED.
If writes go missing, split the characteristic
A characteristic can be readable, writable and notifying all at once, and ble-swarm and the gattpong.js example both use one characteristic for writes and notifications. Holepunch's own cross-platform testing hit one shape that didn't work, though: a single characteristic with read, write, write-without-response and notify, where writes from an iPhone central never reached an Android server. Splitting it in two, read and notify on one, write and write-without-response on the other, worked in both directions. If writes from one platform go missing, try that split first.
L2CAP
Hand over the PSM through GATT, and advertise only when it's ready
The operating system assigns the PSM when you publish a channel, so the other side has to learn it. Put it in a readable characteristic and have the central read it after connecting. Then advertise only once both serviceAdd and channelPublish have fired, or a fast central connects before there's anything to read. Exchange data over L2CAP shows the pattern.
Publish a fresh channel after a session ends
A dead session can leave channel state behind, and the operating system may then refuse a new connection to the same PSM ("L2CAP PSM already connected"). ble-swarm unpublishes the channel and publishes a new one, with a new PSM, after each session ends.
Frame messages on the stream
An L2CAP channel delivers bytes in order, but not necessarily in the chunks they were written in. To send messages, frame them, for example with framed-stream as bare-mobile-doctor does. On Linux, also keep each write at most channel.mtu bytes, since a larger one fails.
Prefer encryption above the link to pairing
publishChannel({ encrypted: true }) requires the devices to be paired, which brings a system prompt on Apple and Android, and a pairRequest your app must answer on Linux. It also only authenticates devices. ble-swarm publishes unencrypted channels and runs a Noise handshake over them, so peers are authenticated by public key with no prompt. See Security.
App lifecycle
Pause the radio in the background
Mobile systems throttle or stop a backgrounded app's Bluetooth activity, and the Apple binding has no support for CoreBluetooth's state restoration. Don't leave scans and connections to fail on their own when the app goes to the background: stop them deliberately, and start them again when it returns, the way ble-swarm's suspend() and resume() do. Handle app suspension covers the pattern for Bare worklets.
Request permissions before the worklet starts
The operating system grants Bluetooth access to the app, not to the Bare code inside it. On Android, request the runtime permissions and start the worklet only once they're granted, so the Bare side never runs without access. Set up Bluetooth in a mobile app shows both a React Native and a native host doing this.
Clean up in the right order
Release things from the top down: tell the peer you're leaving, close streams and L2CAP channels, stop scanning and advertising, disconnect, and only then destroy the managers. destroy() does part of this itself: on Apple, a Server's destroy() stops advertising and removes its services; on Android, it unpublishes its channels.
On iOS, check the current release before relying on destroy(). Two upstream consumers deliberately avoid calling it on Apple platforms: bare-mobile-doctor and ble-swarm both found that the native module's cleanup at runtime teardown freed the same handle a second time after an explicit destroy(), and crashed. bare-bluetooth-apple has since merged teardown fixes (#53, #56), but both consumers still keep their workaround: stop scanning and advertising, call removeAllServices(), close the streams, and let the runtime reclaim the managers. Test teardown on a real device before you change that.
See also
- Bluetooth in Bare—the module family, the roles, and GATT versus L2CAP.
- Connect over Bluetooth—guides that apply these practices.
bare-bluetoothreference—every event and error code mentioned here.- Handle app suspension—the radio and the worklet when the app goes to the background.
Last updated on