Build a Bluetooth chat for iOS and Android
Walk through the Bluetooth chat in bare-mobile-doctor: two phones find each other over Bluetooth Low Energy and trade messages over an L2CAP channel.
This guide walks through a working Bluetooth chat between two phones, with no network in between. The reference implementation is the Bluetooth screen of bare-mobile-doctor, Holepunch's diagnostics app for Bare on mobile. It's an Expo app with this split:
- Every Bluetooth call runs in a Bare worklet, through
bare-bluetooth. - The React Native screen only draws what the worklet reports.
Every phone can advertise and scan at the same time. The screen has a Discoverable switch and a Scan switch, and either phone can invite the other. You tap a nearby phone to invite it, the other side accepts, and from then on messages travel over an L2CAP channel between the two.
For how the pieces fit together, read Bluetooth in Bare first. For why the code waits, normalizes, and times out where it does, read Bluetooth best practices.
bare-bluetooth is experimental upstream, so its API may change. This guide follows bare-mobile-doctor at commit 5bfe51b, which depends on bare-bluetooth ^0.6.3.
Before you begin
- Two physical phones, iOS or Android in any mix. Simulators and emulators can't stand in for the radio, and upstream warns that emulated Android devices can crash under QEMU.
- Android 12 (API level 31) or later: the app sets
minSdkVersionto 31. - The toolchain for the platforms you build: Xcode for iOS, and Android Studio with the SDK, NDK and JDK versions listed in the upstream README.
- The
patchelfbinary, which upstream's README asks for beforenpm install. - Familiarity with running Bare in a React Native app, covered in Embed Bare in a React Native app.
Clone and run
git clone https://github.com/holepunchto/bare-mobile-doctor
cd bare-mobile-doctor
npm install
npm run link
npm run bundleThese commands do the following:
npm run linklinks the native addons that the worklets load,bare-bluetoothamong them, intoreact-native-bare-kit. Run it again after every reinstall.npm run bundlepacks every worklet, including the Bluetooth worklet, withbare-pack.- If your npm config has
ignore-scripts=true, run the install script of@shopify/react-native-skiaby hand. Upstream's README has the command.
Then build and install on each phone:
npm run ios
npm run androidnpm run ios is expo run:ios, which offers simulators as well as devices, so pick your iPhone. Installing on a physical iPhone needs a development team and a provisioning profile. Upstream's iOS release build steps walk through them in Xcode. For Android, upstream's README also lists a Gradle wrapper downgrade to apply after expo prebuild. Without the downgrade, the Android build can fail.
For two Android phones, upstream recommends a release build, which doesn't need a Metro dev server per device:
npx expo run:android --variant release --no-bundlerYou can then install the release APK on the second phone with adb, without rebuilding:
adb -s <serial> install -r android/app/build/outputs/apk/release/app-release.apkOn both phones, open the Bluetooth screen and allow Bluetooth access when asked. Then:
- Turn on Discoverable on one phone. It starts advertising.
- Turn on Scan on the other. The first phone appears in the device list.
- Tap Invite next to it. The first phone shows the invite. Tap Accept.
- Type messages on either side. Disconnect ends the chat on both.
Map the code
| Path | What it is |
|---|---|
bare/bluetooth.js | The worklet. Owns both Bluetooth roles, the L2CAP link and the chat protocol. This is where the Bluetooth code lives. |
app/views/bluetooth/BluetoothTests.tsx | The screen. Requests the Android permissions, starts the worklet and relays its messages to the UI. |
app.json | The iOS usage description and the Android permissions. |
scripts/link.mjs | Links the worklets' native addons. |
package.json gen:bluetooth | Packs bare/bluetooth.js into app/views/bluetooth/bluetooth.bundle.js for every iOS and Android target. |
The platform setup (permissions, linking, bundling) is the same for any Bluetooth app. See Set up Bluetooth in a mobile app for it. The rest of this page is about bare/bluetooth.js.
How a chat starts
The worklet builds three objects. Both phones run all three, so either one can invite the other:
BLECentralscans and connects.BLEServeradvertises and accepts.Sessionruns the invite and chat protocol on top of whichever link opens first.
const ipc = new FramedStream(BareKit.IPC)
const central = new BLECentral({ serviceUUID: SERVICE_UUID, psmUUID: PSM_UUID })
const server = new BLEServer({ serviceUUID: SERVICE_UUID, psmUUID: PSM_UUID })
const session = new Session({
central,
server,
deviceName: Bare.argv[0] || 'BareDevice',
send: (msg) => ipc.write(Buffer.from(JSON.stringify(msg)))
})Here is what happens between the inviting phone (the central) and the invited one (the server):
1. Wait for the radio
Nothing works until the adapter reports 'poweredOn'. Before then, central.startScan() throws, and turning Bluetooth off drops the services that a server has added. The central re-emits the state, and starts a scan that the user asked for while the radio was still off:
this._central.on('stateChange', (bleState) => {
dbg('Central: stateChange', bleState)
this.emit('stateChange', bleState)
if (bleState === 'poweredOn') {
this.emit('ready')
if (this.scanning === ToggleState.REQUESTED) this.startScan()
}
})The server does its setup in _start(), which it calls from its constructor and again on every 'poweredOn':
_start() {
if (this._server.state !== 'poweredOn') return
if (!this.serviceAdded) {
this._server.addService(new Service(this.serviceUUID, [this.psmChar]))
}
if (this._psm == null) this._server.publishChannel()
}This sample has a gap. _start() skips server.addService() once serviceAdded is true, and skips server.publishChannel() once it holds a PSM. Nothing resets either flag when the radio goes off. After a Bluetooth toggle, an Android phone would advertise with no service and a stale PSM, because bare-bluetooth closes the GATT server there. To survive that, clear both flags when the state leaves 'poweredOn'. See Wait for poweredOn, every time.
2. Publish a channel and serve its PSM
An L2CAP channel is identified by a PSM number that the operating system picks when you publish it, so the other phone can't know it in advance. The server solves that with a GATT Service that holds one readable Characteristic, PSM_UUID. It answers every read of that characteristic with the PSM, as text, through server.respondToRequest():
const SERVICE_UUID = 'B4A3C8A7-0000-1000-8000-00805F9B34FB'
// Readable characteristic that carries the L2CAP PSM the server published, so a
// central can learn where to open the connection-oriented channel.
const PSM_UUID = 'B4A3C8A7-0003-1000-8000-00805F9B34FB'
const CONNECT_TIMEOUT_MS = 15000
const isAndroid = Bare.platform === 'android'
const scanOptions = isAndroid ? { scanMode: Central.SCAN_MODE_LOW_LATENCY } : undefined this._server.on('channelPublish', (psm) => {
dbg('Server: channelPublish psm=', psm)
this._psm = psm
this.emit('log', `L2CAP channel published psm=${psm}`)
if (this.advertising === ToggleState.REQUESTED) this.startAdvertising()
})
this._server.on('channelOpen', (channel) => {
dbg('Server: channelOpen (invitee side)')
this.emit('log', 'L2CAP channel open')
this.emit('link', new PeerLink(channel))
})
// A central reads this to discover the PSM before opening the channel. The
// PSM is a plain number, so send it as raw text (not JSON) - the central
// parses it with Number().
this._server.on('readRequest', (req) => {
dbg('Server: readRequest (PSM) psm=', this._psm)
const value = this._psm != null ? Buffer.from(String(this._psm)) : Buffer.alloc(0)
this._server.respondToRequest(req, Server.ATT_SUCCESS, value)
})3. Advertise once both are ready
A central that connects before the service exists, or before there is a PSM to read, gets nothing. So startAdvertising() advertises only once the serviceAdd and channelPublish events have both fired. Until then, it records the request, and those two handlers call it again:
startAdvertising(deviceName) {
if (deviceName !== undefined) this._deviceName = deviceName
if (this.advertising === ToggleState.ON) return
if (this._server.state !== 'poweredOn') {
this.emit('log', 'Advertising is waiting for Bluetooth power')
return
}
// Both the GATT service (for PSM discovery) and the published PSM must be
// ready before we invite centrals to connect.
if (!this.serviceAdded || this._psm == null) {
this._start()
this.advertising = ToggleState.REQUESTED
this.emit('log', 'Advertising is waiting for service and PSM')
return
}
const opts = { serviceUUIDs: [this.serviceUUID] }
if (this._deviceName) opts.name = this._deviceName
this.emit('log', `Starting advertising: ${this.serviceUUID}`)
this._server.startAdvertising(opts)
this.advertising = ToggleState.ON
this.emit('advertisingStarted')
}BLEServer advertises with server.startAdvertising(). The advertisement carries the service UUID, which is what the other phone filters its scan on.
4. Scan for the service
The central scans only for peripherals that advertise the chat's service UUID. On Android, it also passes the low-latency scan mode (the scanOptions constant in step 2). That mode is already the default of bare-bluetooth-android, so passing it only makes the choice explicit:
startScan() {
if (this.scanning === ToggleState.ON) return
if (this._central.state !== 'poweredOn') {
this.emit('log', 'Scan is waiting for Bluetooth power')
return
}
this._central.startScan([this.serviceUUID], scanOptions)
this.scanning = ToggleState.ON
this.emit('scanStarted')
}Every advertisement arrives as a discover event with a DiscoveredPeripheral, often many per phone. The central keeps the latest one per id and reports it to the UI:
this._central.on('discover', (discovered) => {
this.discoveredPeripherals.set(discovered.id, discovered)
let name = discovered.name
if (!name && discovered.serviceData && discovered.serviceData[this.serviceUUID]) {
name = Buffer.from(discovered.serviceData[this.serviceUUID]).toString()
}
this.emit('discovered', { id: discovered.id, name: name || 'Unknown', rssi: discovered.rssi })
})5. Connect, find the PSM and open the channel
Tapping Invite stops the scan with central.stopScan() and connects to the discovered peripheral with central.connect():
connect(id) {
const discovered = this.discoveredPeripherals.get(id)
if (!discovered) {
this.emit('error', 'Device not found: ' + id)
return false
}
if (this.scanning !== ToggleState.OFF) this.stopScan()
this.emit('log', `Connecting to: ${discovered.name || discovered.id || 'unknown'}`)
this._central.connect(discovered)
return true
}Once connected, the central walks down to the PSM one event at a time, on the Peripheral:
- Discover the service with
peripheral.discoverServices(). - Discover its PSM characteristic with
peripheral.discoverCharacteristics(). - Read the characteristic with
peripheral.read(). - Open the channel on the number it reads, with
peripheral.openL2CAPChannel().
_onConnect(peripheral) {
this.peripheral = peripheral
this.emit('log', `Connected: ${peripheral.name || peripheral.id || 'unknown'}`)
peripheral.on('servicesDiscover', (services) => {
dbg('Central: servicesDiscover', services && services.length)
const service = findByUUID(services, this.serviceUUID)
if (!service) {
this.emit('error', 'Chat service not found')
this.emit('connectFailed')
return
}
peripheral.discoverCharacteristics(service, [this.psmUUID])
})
peripheral.on('characteristicsDiscover', (service, chars) => {
dbg('Central: characteristicsDiscover', chars && chars.length)
const psmChar = findByUUID(chars, this.psmUUID)
if (!psmChar) {
this.emit('error', 'PSM characteristic not found')
this.emit('connectFailed')
return
}
this.emit('log', 'Reading L2CAP PSM')
peripheral.read(psmChar)
})
peripheral.on('read', (char, data) => {
const text = Buffer.from(data).toString()
dbg('Central: read PSM char ->', JSON.stringify(text))
const psm = Number(text)
if (!psm) {
this.emit('error', `Invalid PSM: ${JSON.stringify(text)}`)
this.emit('connectFailed')
return
}
this.emit('log', `Opening L2CAP channel psm=${psm}`)
peripheral.openL2CAPChannel(psm)
})
peripheral.on('channelOpen', (channel) => {
dbg('Central: channelOpen (inviter side)')
this.emit('log', 'L2CAP channel open')
this.emit('link', new PeerLink(channel))
})
peripheral.on('error', (err) => {
dbg('Central: peripheral error', err && err.message)
this.emit('error', err.message || String(err))
this.emit('connectFailed')
})
this.emit('log', 'Discovering services')
peripheral.discoverServices([this.serviceUUID])
}findByUUID() doesn't compare UUIDs with ===. iOS reports them uppercase and Android reports them lowercase, so the function normalizes both sides first:
function normalizeUUID(uuid) {
return String(uuid || '')
.toLowerCase()
.replace(/-/g, '')
}
function matchesUUID(a, b) {
return normalizeUUID(a) === normalizeUUID(b)
}
function findByUUID(items, uuid) {
if (!items) return null
for (const item of items) {
if (matchesUUID(item.uuid, uuid)) return item
}
return null
}6. Chat over the channel
When the channel opens, both sides get an L2CAPChannel, which is a duplex stream. From here on, there is no central or server any more. Each side wraps the channel in a PeerLink, which frames it with framed-stream so that every write arrives as one whole message, and sends JSON:
// A framed, bidirectional message channel over an L2CAP Duplex. Both peers use
// the same object: once the channel is open there is no central/server
// asymmetry, so the chat protocol runs identically on either side.
class PeerLink extends EventEmitter {
constructor(channel) {
super()
dbg('PeerLink: new (wrapping L2CAP channel in FramedStream)')
this._stream = new FramedStream(channel)
this._stream.on('data', (data) => {
dbg('PeerLink: data', data.byteLength, 'bytes')
let msg
try {
msg = JSON.parse(Buffer.from(data).toString())
} catch (e) {
this.emit('error', `Bad BLE data (${data.byteLength} bytes)`)
return
}
this.emit('message', msg)
})
// L2CAP is reliable: a peer leaving surfaces as end/close/error on the
// stream, so we don't need platform-specific disconnect detection.
this._stream.on('close', () => {
dbg('PeerLink: stream close')
this.emit('close')
})
this._stream.on('error', (err) => {
dbg('PeerLink: stream error', err && err.message)
this.emit('close')
})
}
send(msg) {
dbg('PeerLink: send', msg.t)
this._stream.write(serialize(msg))
}
close() {
dbg('PeerLink: close()')
this._stream.destroy()
}
}The chat protocol has five messages, handled in Session.handlePeerMessage():
| Message | Sent by | Meaning |
|---|---|---|
{ t: 'invite', n } | inviter, as soon as the channel opens | "n wants to chat" |
{ t: 'accept' } | invitee | start the chat |
{ t: 'reject' } | invitee | decline; the inviter tears the link down |
{ t: 'msg', d } | either | a chat message with text d |
{ t: 'disconnect' } | either | the chat is over |
L2CAP is reliable, so a peer that closes the channel shows up as close or error on the stream, the same on both platforms. That's why PeerLink needs no platform-specific disconnect detection for that case. A peer that vanishes without closing the channel takes longer to notice. See Notice a peer that has vanished.
Handle failures
On iOS and Android, a failed connection attempt and a disconnect that an error causes both arrive as an error event rather than a disconnect event. The central routes those two codes, CONNECTION_FAILED and DISCONNECT, back into the session. Otherwise, the session would stay stuck in "connecting":
this._central.on('error', (err) => {
dbg('Central: error', err && err.code, err && err.message)
this.emit('error', err.message || String(err))
// iOS & Android report errored connects/disconnects as 'error' (not
// 'disconnect'), so route the codes or the session stays stuck connected.
if (err && err.code === 'CONNECTION_FAILED') {
this.emit('connectFailed')
} else if (err && err.code === 'DISCONNECT') {
this.emit('closed')
}
})bare-bluetooth sets no timeout on a connection attempt either, so the session sets its own, 15 seconds, and goes back to scanning when it fires:
startConnectTimeout() {
this.clearConnectTimeout()
this.state.connectTimer = setTimeout(() => {
this.state.connectTimer = null
if (this.state.inviteRole !== 'inviter' || this.state.link) return
this.teardown()
this.send({ type: 'error', message: 'Connection timed out' })
this.send({ type: 'disconnected' })
this.central.setScan(true)
}, CONNECT_TIMEOUT_MS)
}This timer abandons the session, but it doesn't cancel the pending connection. BLECentral.disconnect() acts only once a peripheral has connected, so a late connect event can still arrive after the timeout. In your own code, call central.disconnect() with the object that you passed to connect(), the way the Apple test in Reconnect without scanning does.
Tear down
Ending a chat closes the stream and drops the Bluetooth connection, whichever role this phone played:
// Close the link and drop any BLE connection we opened. Safe to call from any
// role: central.disconnect() is a no-op when we never connected.
teardown() {
dbg('Session: teardown (role=' + this.state.inviteRole + ')')
if (this.state.link) {
this.state.link.close()
this.state.link = null
}
this.central.disconnect()
this.state.inviteRole = 'idle'
}When the user leaves the screen, the React Native side takes two steps:
- It sends
disconnectto the worklet, so the other phone hears about it right away instead of waiting for the Bluetooth supervision timeout. - It terminates the worklet a moment later.
return () => {
const stream = streamRef.current
const worklet = workletRef.current
streamRef.current = null
// Tell the peer we're leaving so it tears down cleanly instead of waiting
// for the BLE supervision timeout, then give the worklet a moment to write
// the disconnect over the air before terminating it.
try {
stream?.write(b4a.from(JSON.stringify({ type: 'disconnect' })))
} catch {}
setTimeout(() => worklet?.terminate(), 150)
}On exit, the worklet deliberately skips server.destroy() and central.destroy(). A comment in the code says that on iOS the native module's runtime-teardown cleanup frees the same handle a second time after an explicit destroy(), and crashes. bare-bluetooth-apple fixed that cleanup in July 2026, before the comment was written, so the workaround may be out of date. Test teardown on a device before you copy it or drop it. See Clean up in the right order.
// We only release our own state here (connect timer + L2CAP link). We should
// also call server.destroy()/central.destroy() to own their teardown, but the
// native module's on_cleanup double-frees the same V8 handle at runtime
// teardown and crashes (EXC_BAD_ACCESS in central__on_cleanup).
// TODO: restore server.destroy()/central.destroy() once bare-bluetooth-apple
// makes on_cleanup idempotent w.r.t. an explicit destroy().
destroy() {
dbg('Session: destroy')
this.clearConnectTimeout()
if (this.state.link) {
this.state.link.close()
this.state.link = null
}
}Remix it
The channel is a plain duplex stream, so the chat is only one use of it:
| Build | Change |
|---|---|
| File or photo transfer | Keep PeerLink's framing and send binary frames instead of JSON. |
| A protocol of your own | Replace Session.handlePeerMessage() and the t tags. The discovery and channel code stays as it is. |
| Encrypted, replicating peers | Use ble-swarm instead. It runs a Noise handshake over each Bluetooth connection and hands you the same kind of encrypted stream as Hyperswarm, ready for corestore.replicate(). |
Where to go next
- Set up Bluetooth in a mobile app: the permissions and build steps this app depends on.
- Exchange data over L2CAP: the same channel from two laptops, with plain
bare. - Bluetooth best practices: why this code waits, normalizes and times out where it does.
bare-bluetoothreference: every class, event and constant.- Handle app suspension: what to do with the radio when the app goes to the background.
Last updated on