Bare Docs

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 minSdkVersion to 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 patchelf binary, which upstream's README asks for before npm 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 bundle

These commands do the following:

  • npm run link links the native addons that the worklets load, bare-bluetooth among them, into react-native-bare-kit. Run it again after every reinstall.
  • npm run bundle packs every worklet, including the Bluetooth worklet, with bare-pack.
  • If your npm config has ignore-scripts=true, run the install script of @shopify/react-native-skia by hand. Upstream's README has the command.

Then build and install on each phone:

npm run ios
npm run android

npm 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-bundler

You 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.apk

On both phones, open the Bluetooth screen and allow Bluetooth access when asked. Then:

  1. Turn on Discoverable on one phone. It starts advertising.
  2. Turn on Scan on the other. The first phone appears in the device list.
  3. Tap Invite next to it. The first phone shows the invite. Tap Accept.
  4. Type messages on either side. Disconnect ends the chat on both.

Map the code

PathWhat it is
bare/bluetooth.jsThe worklet. Owns both Bluetooth roles, the L2CAP link and the chat protocol. This is where the Bluetooth code lives.
app/views/bluetooth/BluetoothTests.tsxThe screen. Requests the Android permissions, starts the worklet and relays its messages to the UI.
app.jsonThe iOS usage description and the Android permissions.
scripts/link.mjsLinks the worklets' native addons.
package.json gen:bluetoothPacks 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:

  • BLECentral scans and connects.
  • BLEServer advertises and accepts.
  • Session runs the invite and chat protocol on top of whichever link opens first.
bare/bluetooth.js
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):

poweredOn: addService(PSM characteristic), publishChannel() serviceAdd + channelPublish(psm): startAdvertising() poweredOn: startScan([SERVICE_UUID]) advertisement (discover) connect() discoverServices, discoverCharacteristics read(PSM characteristic) readRequest answered with the PSM openL2CAPChannel(psm) { t: 'invite' } { t: 'accept' } { t: 'msg' } (both ways) channelOpen on both sides Inviter (Central) Invitee (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:

bare/bluetooth.js
    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':

bare/bluetooth.js
  _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():

bare/bluetooth.js
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
bare/bluetooth.js
    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:

bare/bluetooth.js
  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:

bare/bluetooth.js
  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:

bare/bluetooth.js
    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():

bare/bluetooth.js
  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:

  1. Discover the service with peripheral.discoverServices().
  2. Discover its PSM characteristic with peripheral.discoverCharacteristics().
  3. Read the characteristic with peripheral.read().
  4. Open the channel on the number it reads, with peripheral.openL2CAPChannel().
bare/bluetooth.js
  _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:

bare/bluetooth.js
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:

bare/bluetooth.js
// 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():

MessageSent byMeaning
{ t: 'invite', n }inviter, as soon as the channel opens"n wants to chat"
{ t: 'accept' }inviteestart the chat
{ t: 'reject' }inviteedecline; the inviter tears the link down
{ t: 'msg', d }eithera chat message with text d
{ t: 'disconnect' }eitherthe 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":

bare/bluetooth.js
    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:

bare/bluetooth.js
  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:

bare/bluetooth.js
  // 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:

  1. It sends disconnect to the worklet, so the other phone hears about it right away instead of waiting for the Bluetooth supervision timeout.
  2. It terminates the worklet a moment later.
app/views/bluetooth/BluetoothTests.tsx
    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.

bare/bluetooth.js
  // 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:

BuildChange
File or photo transferKeep PeerLink's framing and send binary frames instead of JSON.
A protocol of your ownReplace Session.handlePeerMessage() and the t tags. The discovery and channel code stays as it is.
Encrypted, replicating peersUse 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

Last updated on

Was this helpful?

On this page