Bluetooth best practices
The rules that make Bare Bluetooth code reliable on iOS, Android, macOS and Linux, from waiting for the radio to timeouts, flow control and teardown.
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, a Hyperswarm-shaped Bluetooth transport (version 2.3.5 here). Each one says why, and where upstream does it. All of these projects are experimental upstream, so recheck a rule against the version you use.
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 most failures 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().
Wrap calls in try/catch too
Some failures are thrown instead. central.startScan() throws a BluetoothError with code NOT_POWERED_ON. The Android binding throws the Java exception when a call itself fails, for example when a runtime permission is missing. Linux's central.disconnect() throws when given a DiscoveredPeripheral, and the platform packages throw from calls such as retrievePeripherals() and getBondedDevices(). ble-swarm wraps its calls in try/catch for this reason, including requestMtu() and openL2CAPChannel().
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-bluetooth-apple's own test gives up after 5 seconds and does exactly that, noting that a pending attempt otherwise "outlives this loop and fires later."
bare-mobile-doctor gives up after 15 seconds and goes back to scanning, but it doesn't cancel the attempt. Its BLECentral.disconnect() only acts once a peripheral has connected, so a late connect can still arrive after the timeout. Cancel the attempt yourself.
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 closes the channel 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 for that case. A peer that simply disappears is slower to notice, which is why ble-swarm keeps its keepalives even over L2CAP.
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 an oversized one may fail with ADVERTISE_FAILED or be accepted and cut down, depending on the platform. Keep it to the service UUID.
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 reports those in the Bluetooth base range in their short 16-bit form (180D). 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 uppercase comparison in the bare-bluetooth README, for one, fails on Android and Linux. 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:
_onWriteRequests(requests) {
const ok = this.server.constructor.ATT_SUCCESS ?? 0
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)
}
}This is _onWriteRequests(), copied without its class indentation.
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.
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, as bare-mobile-doctor does. (ble-swarm also sends the PSM over GATT, but inside a HELLO frame it notifies on its data characteristic.) 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.
Put a deadline on opening a channel
peripheral.openL2CAPChannel() can hang without an error or a disconnect. ble-swarm found that an iOS peripheral never answers a channel opened by a macOS central, and the link just sits there. It runs every open under a 3-second deadline and treats a silent hang as a failed dial, which it retries. Do the same: start a timer when you call openL2CAPChannel(), and cancel the connection if no channelOpen arrives.
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, test teardown on a real device before relying on destroy(). Two upstream consumers skip it there: a comment in bare-mobile-doctor says the native module's cleanup at runtime teardown frees the same handle a second time after an explicit destroy(), and ble-swarm calls removeAllServices() instead on iOS for the same reason. (ble-swarm does call destroy() on macOS and Android.)
That workaround may be out of date. bare-bluetooth-apple made this cleanup safe in July 2026 (#53, #56 and #58, shipped since 0.3.x), and its tests include destroy then exit does not double-free. Both consumers' comments are newer than those fixes, and bare-bluetooth 0.6.2 and later require bare-bluetooth-apple 0.7.0 or later, so they already have them. These docs haven't confirmed on a device that the crash is gone. Until that's confirmed, the safe order on iOS is the one the consumers use: stop scanning and advertising, call removeAllServices(), close the streams, and let the runtime reclaim the managers.
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