# Build a Bluetooth chat for iOS and Android



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`](https://github.com/holepunchto/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](/bare/reference/bare/bare-kit#worklet), through [`bare-bluetooth`](/bare/reference/bare/modules/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.

<Callout type="tip">
  For how the pieces fit together, read [Bluetooth in Bare](/bare/explanation/bluetooth) first. For why the code waits, normalizes, and times out where it does, read [Bluetooth best practices](/bare/explanation/bluetooth/best-practices).
</Callout>

<Callout type="info">
  `bare-bluetooth` is experimental upstream, so its API may change. This guide follows `bare-mobile-doctor` at commit [`5bfe51b`](https://github.com/holepunchto/bare-mobile-doctor/tree/5bfe51b288fa66ed524502fd46fff3ef2038b4fe), which depends on `bare-bluetooth` `^0.6.3`.
</Callout>

## Before you begin [#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`](https://developer.android.com/guide/topics/manifest/uses-sdk-element) 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](https://github.com/holepunchto/bare-mobile-doctor#android).
* The [`patchelf`](https://github.com/NixOS/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](/bare/how-to/run-on-native/embed-bare-in-react-native).

## Clone and run [#clone-and-run]

```bash skip="install-instruction"
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, such as `bare-bluetooth-apple` and `bare-bluetooth-android`, into [`react-native-bare-kit`](https://github.com/holepunchto/react-native-bare-kit). Run it again after every reinstall. On iOS, run it before `pod install`, because the pod picks up the linked `.xcframework` bundles when CocoaPods reads the podspec.
* `npm run bundle` packs every worklet, including the Bluetooth worklet, with [`bare-pack`](/bare/reference/bare/modules/bare-pack).
* If your npm config has `ignore-scripts=true`, run the install script of `@shopify/react-native-skia` by hand. Upstream's [README](https://github.com/holepunchto/bare-mobile-doctor#usage) has the command.

Then build and install on each phone:

```bash skip="native-build"
npm run ios
npm run android
```

`npm run ios` is [`expo run:ios`](https://docs.expo.dev/more/expo-cli/#compiling), 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](https://github.com/holepunchto/bare-mobile-doctor#release-builds) walk through them in Xcode. For Android, upstream's [README](https://github.com/holepunchto/bare-mobile-doctor#android) also lists a Gradle wrapper downgrade to apply after any [`expo prebuild`](https://docs.expo.dev/workflow/prebuild/). Without the downgrade, the Android build can fail. `npm run android` prebuilds the `android/` folder only when it's missing, so on a fresh clone:

1. Run `npx expo prebuild --platform android`.
2. Apply the downgrade from the README. On macOS, its `sed -i` command needs an empty suffix: `sed -i ''`.
3. Run `npm run android`.

For two Android phones, upstream recommends a release build, which doesn't need a Metro dev server per device:

```bash skip="native-build"
npx expo run:android --variant release --no-bundler
```

You can then install the release APK on the second phone with [`adb`](https://developer.android.com/tools/adb), without rebuilding:

```bash skip="native-build"
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 [#map-the-code]

| Path                                                                                                                                                                               | What it is                                                                                                                |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| [`bare/bluetooth.js`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/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`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/app/views/bluetooth/BluetoothTests.tsx) | The screen. Requests the Android permissions, starts the worklet and relays its messages to the UI.                       |
| [`app.json`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/app.json)                                                             | The iOS usage description and the Android permissions.                                                                    |
| [`scripts/link.mjs`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/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](/bare/how-to/connect-over-bluetooth/set-up-bluetooth-in-a-mobile-app) for it. The rest of this page is about `bare/bluetooth.js`.

## How a chat starts [#how-a-chat-starts]

The worklet builds three objects. Both phones run all three, so either one can invite the other:

* [`BLECentral`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/bare/bluetooth.js#L106) scans and connects.
* [`BLEServer`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/bare/bluetooth.js#L281) advertises and accepts.
* [`Session`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/bare/bluetooth.js#L423) runs the invite and chat protocol on top of whichever link opens first.

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L679-L689 title="bare/bluetooth.js" skip="example-import"
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):

<Mermaid
  chart="sequenceDiagram
  participant C as Inviter (Central)
  participant S as Invitee (Server)
  S->>S: poweredOn: addService(PSM characteristic), publishChannel()
  S->>S: serviceAdd + channelPublish(psm): startAdvertising()
  C->>C: poweredOn: startScan([SERVICE_UUID])
  S-->>C: advertisement (discover)
  C->>S: connect()
  C->>S: discoverServices, discoverCharacteristics
  C->>S: read(PSM characteristic)
  S-->>C: readRequest answered with the PSM
  C->>S: openL2CAPChannel(psm)
  Note over C,S: channelOpen on both sides
  C->>S: { t: 'invite' }
  S-->>C: { t: 'accept' }
  C->>S: { t: 'msg' } (both ways)"
/>

### 1. Wait for the radio [#1-wait-for-the-radio]

Nothing works until the adapter reports [`'poweredOn'`](/bare/reference/bare/modules/bare-bluetooth#bluetoothstate). Before then, [`central.startScan()`](/bare/reference/bare/modules/bare-bluetooth#centralstartscanserviceuuids-options) 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:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L125-L132 title="bare/bluetooth.js" skip="example-import"
    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'`:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L351-L357 title="bare/bluetooth.js" skip="example-import"
  _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()
  }
```

<Callout type="warn">
  This sample has a gap. `_start()` skips [`server.addService()`](/bare/reference/bare/modules/bare-bluetooth#serveraddserviceservice) once `serviceAdded` is true, and skips [`server.publishChannel()`](/bare/reference/bare/modules/bare-bluetooth#serverpublishchanneloptions) once it holds a PSM. Nothing resets either flag when the radio goes off. The advertising state has the same problem. It stays `ON`, `startAdvertising()` returns early while it is `ON`, and the `serviceAdd` and `channelPublish` handlers advertise only from `REQUESTED`. Turning Bluetooth off stops the advertisement, so after a toggle the phone stops advertising while the UI still shows Discoverable. On Android, `bare-bluetooth` also closes the GATT server, so the service is gone too. To survive that, when the state leaves `'poweredOn'`, clear `serviceAdded` and the PSM, and set advertising back to `REQUESTED` if the user had it on. See [Wait for `poweredOn`, every time](/bare/explanation/bluetooth/best-practices#wait-for-poweredon-every-time).
</Callout>

### 2. Publish a channel and serve its PSM [#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`](/bare/reference/bare/modules/bare-bluetooth#const-service--new-serviceuuid-characteristics-options) that holds one readable [`Characteristic`](/bare/reference/bare/modules/bare-bluetooth#const-characteristic--new-characteristicuuid-options), `PSM_UUID`. It answers every read of that characteristic with the PSM, as text, through [`server.respondToRequest()`](/bare/reference/bare/modules/bare-bluetooth#serverrespondtorequestrequest-result-data):

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L5-L12 title="bare/bluetooth.js" skip="example-import"
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
```

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L315-L335 title="bare/bluetooth.js" skip="example-import"
    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 [#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`](/bare/reference/bare/modules/bare-bluetooth#server) and [`channelPublish`](/bare/reference/bare/modules/bare-bluetooth#server) events have both fired. Until then, it records the request, and those two handlers call it again:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L372-L397 title="bare/bluetooth.js" skip="example-import"
  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()`](/bare/reference/bare/modules/bare-bluetooth#serverstartadvertisingoptions). The advertisement carries the service UUID, which is what the other phone filters its scan on.

### 4. Scan for the service [#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:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L226-L237 title="bare/bluetooth.js" skip="example-import"
  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`](/bare/reference/bare/modules/bare-bluetooth#central) event with a [`DiscoveredPeripheral`](/bare/reference/bare/modules/bare-bluetooth#discoveredperipheral), often many per phone. The central keeps the latest one per `id` and reports it to the UI:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L134-L143 title="bare/bluetooth.js" skip="example-import"
    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 [#5-connect-find-the-psm-and-open-the-channel]

Tapping **Invite** stops the scan with [`central.stopScan()`](/bare/reference/bare/modules/bare-bluetooth#centralstopscan) and connects to the discovered peripheral with [`central.connect()`](/bare/reference/bare/modules/bare-bluetooth#centralconnectperipheral):

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L255-L267 title="bare/bluetooth.js" skip="example-import"
  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`](/bare/reference/bare/modules/bare-bluetooth#peripheral):

1. Discover the service with [`peripheral.discoverServices()`](/bare/reference/bare/modules/bare-bluetooth#peripheraldiscoverservicesserviceuuids).
2. Discover its PSM characteristic with [`peripheral.discoverCharacteristics()`](/bare/reference/bare/modules/bare-bluetooth#peripheraldiscovercharacteristicsservice-characteristicuuids).
3. Read the characteristic with [`peripheral.read()`](/bare/reference/bare/modules/bare-bluetooth#peripheralreadcharacteristic).
4. Open the channel on the number it reads, with [`peripheral.openL2CAPChannel()`](/bare/reference/bare/modules/bare-bluetooth#peripheralopenl2capchannelpsm).

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L170-L224 title="bare/bluetooth.js" skip="example-import"
  _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:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L24-L40 title="bare/bluetooth.js" skip="example-import"
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 [#6-chat-over-the-channel]

When the channel opens, both sides get an [`L2CAPChannel`](/bare/reference/bare/modules/bare-bluetooth#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`](https://github.com/holepunchto/bare-mobile-doctor/blob/5bfe51b288fa66ed524502fd46fff3ef2038b4fe/bare/bluetooth.js#L63), which frames it with [`framed-stream`](https://github.com/holepunchto/framed-stream) so that every write arrives as one whole message, and sends JSON:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L60-L102 title="bare/bluetooth.js" skip="example-import"
// 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`](/bare/reference/bare/modules/bare-stream#duplexevents) 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](/bare/explanation/bluetooth/best-practices#notice-a-peer-that-has-vanished).

## Handle failures [#handle-failures]

On iOS and Android, a failed connection attempt and a disconnect that an error causes both arrive as an [`error`](/bare/reference/bare/modules/bare-bluetooth#bluetootherror) event rather than a [`disconnect`](/bare/reference/bare/modules/bare-bluetooth#central) event. The central routes those two codes, `CONNECTION_FAILED` and `DISCONNECT`, back into the session. Otherwise, a failed attempt would leave the session stuck connecting, and a dropped link would leave it stuck connected:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L156-L167 title="bare/bluetooth.js" skip="example-import"
    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:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L601-L612 title="bare/bluetooth.js" skip="example-import"
  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)
  }
```

<Callout type="warn">
  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`](/bare/reference/bare/modules/bare-bluetooth#central) event can still arrive after the timeout. In your own code, call [`central.disconnect()`](/bare/reference/bare/modules/bare-bluetooth#centraldisconnectperipheral) with the object that you passed to `connect()`, the way the Apple test in [Reconnect without scanning](/bare/how-to/connect-over-bluetooth/use-platform-specific-bluetooth-features#apple-reconnect-without-scanning) does.
</Callout>

## Tear down [#tear-down]

Ending a chat closes the stream and drops the Bluetooth connection, whichever role this phone played:

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L549-L559 title="bare/bluetooth.js" skip="example-import"
  // 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.

```tsx file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/app/views/bluetooth/BluetoothTests.tsx#L183-L196 title="app/views/bluetooth/BluetoothTests.tsx" skip="example-import"
    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)
    }
```

<Callout type="warn">
  On exit, the worklet deliberately skips [`server.destroy()`](/bare/reference/bare/modules/bare-bluetooth#serverdestroy) and [`central.destroy()`](/bare/reference/bare/modules/bare-bluetooth#centraldestroy). 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/explanation/bluetooth/best-practices#clean-up-in-the-right-order).
</Callout>

```js file=<rootDir>/examples/how-to/connect-over-bluetooth/bare-mobile-doctor/bare/bluetooth.js#L653-L666 title="bare/bluetooth.js" skip="example-import"
  // 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 [#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`](/bare/reference/bare/modules/ble-swarm) instead. It runs a [Noise](https://noiseprotocol.org/) handshake over each Bluetooth connection and hands you the same kind of encrypted stream as [Hyperswarm](/p2p/reference/building-blocks/hyperswarm), ready for [`corestore.replicate()`](/p2p/reference/helpers/corestore#storereplicateoptsorstream). |

## Where to go next [#where-to-go-next]

* [Set up Bluetooth in a mobile app](/bare/how-to/connect-over-bluetooth/set-up-bluetooth-in-a-mobile-app): the permissions and build steps this app depends on.
* [Exchange data over L2CAP](/bare/how-to/connect-over-bluetooth/exchange-data-over-l2cap): the same channel from two laptops, with plain `bare`.
* [Bluetooth best practices](/bare/explanation/bluetooth/best-practices): why this code waits, normalizes and times out where it does.
* [`bare-bluetooth` reference](/bare/reference/bare/modules/bare-bluetooth): every class, event and constant.
* [Handle app suspension](/bare/how-to/run-on-native/handle-app-suspension): what to do with the radio when the app goes to the background.
