BLE & NFC Patterns

SkillAI & models

BLE scanning/connecting/GATT operations with react-native-ble-plx, NFC tag reading/writing with react-native-nfc-manager, permissions, background mode, battery-efficient patterns

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the BLE & NFC Patterns skill

What this skill tells your AI

The instructions your AI receives, as published by agents-inc/skills in src/skills/mobile-hardware-ble-nfc/SKILL.md and read by ahel’s review.

Quick Guide: Use react-native-ble-plx for BLE (scanning, connecting, GATT read/write/monitor). Use react-native-nfc-manager for NFC (NDEF read/write, tag technology access). BLE values are Base64-encoded -- decode before use. NFC operations follow request-technology/operate/cancel-technology lifecycle. Always clean up: remove BLE subscriptions, call cancelTechnologyRequest() for NFC, and destroy() the BleManager. MTU defaults to 23 bytes (20 usable) -- negotiate higher on Android. iOS auto-negotiates up to 187 bytes.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST call destroy() on BleManager when deallocating resources -- leaking the manager causes native memory leaks and zombie listeners)

(You MUST call discoverAllServicesAndCharacteristics() after connecting before any read/write/monitor operations -- GATT structure is not available until discovered)

(You MUST call cancelTechnologyRequest() in a finally block after every NFC operation -- failing to release the NFC session blocks subsequent scans)

(You MUST check BLE adapter state (PoweredOn) before scanning -- scanning while powered off or unauthorized throws errors silently on some devices)

(You MUST remove all BLE subscriptions (scan listeners, characteristic monitors, disconnect listeners) on cleanup -- leaked subscriptions cause crashes after component unmount)

</critical_requirements>


Auto-detection: react-native-ble-plx, BleManager, startDeviceScan, connectToDevice, monitorCharacteristicForDevice, writeCharacteristicWithResponseForDevice, readCharacteristicForDevice, requestMTUForDevice, react-native-nfc-manager, NfcManager, NfcTech, Ndef, requestTechnology, cancelTechnologyRequest, writeNdefMessage, ndefHandler, useCodeScanner BLE, BLE scanning, NFC tag, NDEF record, characteristic notification, GATT

When to use:

  • Scanning for and connecting to BLE peripherals (IoT sensors, wearables, medical devices)
  • Reading/writing BLE GATT characteristics and monitoring notifications
  • Reading NDEF tags or writing NDEF records to NFC tags
  • Implementing background BLE reconnection with state restoration
  • MTU negotiation for large data transfers over BLE
  • Accessing low-level NFC technologies (NfcA, IsoDep, MifareUltralight)

When NOT to use:

  • Classic Bluetooth audio/file transfer (different protocol, different libraries)
  • BLE peripheral/server mode (react-native-ble-plx is central-only)
  • Web-based Bluetooth (use Web Bluetooth API)
  • Wi-Fi Direct or peer-to-peer networking

Key patterns covered:

  • BLE lifecycle: scan, connect, discover services, read/write/monitor, disconnect
  • Battery-efficient scanning with UUID filters and scan modes
  • MTU negotiation (Android explicit, iOS automatic)
  • Characteristic subscriptions (notifications/indications) with cleanup
  • BLE reconnection and disconnect monitoring
  • NFC NDEF read/write lifecycle with technology request/cancel
  • NFC technology types and platform availability (iOS vs Android)
  • Permission handling for both BLE and NFC

Detailed Resources:

  • examples/core.md - BLE scanning, connecting, GATT operations, disconnect handling
  • examples/nfc.md - NFC NDEF reading/writing, technology types, platform differences
  • reference.md - API quick reference, permission matrix, decision frameworks

Philosophy

BLE and NFC are hardware communication protocols with fundamentally different interaction models:

BLE is connection-oriented and long-lived. You scan for devices, establish a persistent connection, discover the GATT service/characteristic tree, then read/write/subscribe to characteristics over time. Connections can last minutes to hours. The main challenges are connection lifecycle management, reconnection, and battery-efficient scanning.

NFC is session-oriented and brief. You request a technology, tap a tag, perform one operation (read or write), and release the technology. Sessions last seconds. The main challenges are platform differences (iOS vs Android technology support) and ensuring proper cleanup.

Core principles:

  1. Lifecycle-driven -- BLE connections have a strict flow: scan -> connect -> discover -> operate -> disconnect. Skipping steps causes silent failures.
  2. Subscription-based -- BLE characteristic monitoring returns Subscription objects that MUST be removed on cleanup. NFC technology requests MUST be canceled in finally blocks.
  3. Base64-encoded -- All BLE characteristic values are Base64-encoded strings. Decode before use, encode before write.
  4. Platform-aware -- BLE permissions differ significantly between Android versions. NFC technology support varies between iOS and Android. Always check platform capabilities.

When to use BLE:

  • Continuous communication with a peripheral (sensor readings, device control)
  • Background monitoring (health devices, beacons)
  • Large data transfers requiring MTU negotiation

When to use NFC:

  • One-tap interactions (read a tag, write a tag, verify identity)
  • Quick data exchange without pairing
  • Tag provisioning or configuration

When NOT to use either:

  • High-bandwidth streaming (use Wi-Fi or classic Bluetooth)
  • Cross-platform web apps (use Web Bluetooth / Web NFC)

Core Patterns

Pattern 1: BLE Manager Initialization and State

Create one BleManager instance for the app lifetime. Check adapter state before operations.

import { BleManager, State } from "react-native-ble-plx";

const manager = new BleManager();

// Wait for Bluetooth to be ready
manager.onStateChange((state) => {
  if (state === State.PoweredOn) {
    // Safe to scan
  }
}, true); // true = emit current state immediately

// Cleanup on app teardown
manager.destroy();

Why good: single manager instance, state checked before operations, destroy called on cleanup, true flag emits current state immediately so you don't miss the initial PoweredOn

See examples/core.md for full initialization with background mode support and state restoration.


Pattern 2: BLE Scanning with Filters

Filter scans by service UUIDs for battery efficiency. Stop scanning as soon as you find the target device.

const HEART_RATE_SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb";
const SCAN_TIMEOUT_MS = 10000;

manager.startDeviceScan(
  [HEART_RATE_SERVICE_UUID], // Filter by service UUID -- null scans ALL devices
  { allowDuplicates: false },
  (error, device) => {
    if (error) {
      /* handle */ return;
    }
    if (device?.name === "MyDevice") {
      manager.stopDeviceScan();
      // Connect to device
    }
  },
);

// Always set a timeout to stop scanning
setTimeout(() => manager.stopDeviceScan(), SCAN_TIMEOUT_MS);

Why good: UUID filter reduces battery drain, scan timeout prevents indefinite scanning, duplicates disabled to reduce callback noise, scan stopped once target found

See examples/core.md for complete scanning with Android scan modes and permission handling.


Pattern 3: BLE Connection and GATT Discovery

Connect, discover services/characteristics, then operate. Always monitor for disconnects.

const device = await manager.connectToDevice(deviceId, {
  requestMTU: 512, // Android only -- request larger MTU during connection
});

// MUST discover before read/write/monitor
await device.discoverAllServicesAndCharacteristics();

// Monitor for unexpected disconnects
const disconnectSubscription = device.onDisconnected(
  (error, disconnectedDevice) => {
    // Handle reconnection logic
  },
);

// Cleanup
disconnectSubscription.remove();
await device.cancelConnection();

Why good: MTU requested during connection (Android), GATT discovered before operations, disconnect monitored for reconnection, subscription removed on cleanup

See examples/core.md for full connection flow with retry logic and error handling.


Pattern 4: Reading and Writing BLE Characteristics

All values are Base64-encoded. Decode after read, encode before write.

import { decode as atob, encode as btoa } from "base-64";

const SERVICE_UUID = "0000180d-0000-1000-8000-00805f9b34fb";
const CHAR_UUID = "00002a37-0000-1000-8000-00805f9b34fb";

// Read
const characteristic = await device.readCharacteristicForService(
  SERVICE_UUID,
  CHAR_UUID,
);
const decodedValue = atob(characteristic.value ?? "");

// Write with response (acknowledged)
const base64Value = btoa("command-data");
await device.writeCharacteristicWithResponseForService(
  SERVICE_UUID,
  CHAR_UUID,
  base64Value,
);

// Write without response (faster, no acknowledgment)
await device.writeCharacteristicWithoutResponseForService(
  SERVICE_UUID,
  CHAR_UUID,
  base64Value,
);

Why good: named constants for UUIDs, Base64 decode/encode explicit, write-with-response used for reliable delivery, write-without-response available for speed

See examples/core.md for complete read/write patterns with error handling.


Pattern 5: Characteristic Monitoring (Notifications)

Subscribe to characteristic value changes. Returns a Subscription that MUST be removed.

const subscription = device.monitorCharacteristicForService(
  SERVICE_UUID,
  CHAR_UUID,
  (error, characteristic) => {
    if (error) {
      /* handle */ return;
    }
    const value = atob(characteristic?.value ?? "");
    // Process incoming notification
  },
);

// CRITICAL: Remove on cleanup
subscription.remove();

Why good: subscription stored for cleanup, error handled in callback, value decoded from Base64

Gotcha: Check characteristic.isNotifiable or characteristic.isIndicatable before monitoring -- not all characteristics support notifications.

See examples/core.md for monitoring with React hooks and cleanup patterns.


Pattern 6: MTU Negotiation

Larger MTU = fewer packets for big payloads. iOS negotiates automatically (up to 187 bytes). Android requires explicit request.

import { Platform } from "react-native";

const DESIRED_MTU = 512;
const BLE_HEADER_BYTES = 3;

// Request MTU after connection (Android only -- iOS auto-negotiates)
if (Platform.OS === "android") {
  const updatedDevice = await device.requestMTU(DESIRED_MTU);
  const usableBytes = updatedDevice.mtu - BLE_HEADER_BYTES;
  // usableBytes is the max payload per packet
}

Why good: platform check avoids unnecessary call on iOS, named constants for MTU and header size, usable bytes calculated correctly (MTU minus 3-byte ATT header)

See reference.md for MTU size recommendations by use case.


Pattern 7: NFC NDEF Read

Request NDEF technology, read the tag, clean up in finally block.

import NfcManager, { NfcTech } from "react-native-nfc-manager";

// Initialize once at app start
await NfcManager.start();

async function readNdefTag(): Promise<string | null> {
  try {
    await NfcManager.requestTechnology(NfcTech.Ndef);
    const tag = await NfcManager.getTag();

    if (tag?.ndefMessage && tag.ndefMessage.length > 0) {
      // tag.ndefMessage is an array of NDEF records
      // Each record has: tnf, type, id, payload (all number arrays)
      return String.fromCharCode(...tag.ndefMessage[0].payload);
    }
    return null;
  } catch (error) {
    // User cancelled or tag not found
    return null;
  } finally {
    // CRITICAL: Always release the NFC session
    await NfcManager.cancelTechnologyRequest();
  }
}

Why good: technology request and cancel in try/finally, error handled for user cancellation, start() called once at app initialization, tag null-checked before access

See examples/nfc.md for complete NDEF reading with record parsing.


Pattern 8: NFC NDEF Write

Request technology, encode the message, write, release.

import NfcManager, { NfcTech, Ndef } from "react-native-nfc-manager";

async function writeNdefTag(url: string): Promise<boolean> {
  try {
    await NfcManager.requestTechnology(NfcTech.Ndef);
    const bytes = Ndef.encodeMessage([Ndef.uriRecord(url)]);
    await NfcManager.ndefHandler.writeNdefMessage(bytes);
    return true;
  } catch (error) {
    return false;
  } finally {
    await NfcManager.cancelTechnologyRequest();
  }
}

Why good: Ndef utility encodes records properly, try/finally ensures cleanup, boolean return signals success/failure

See examples/nfc.md for text records, multi-record messages, and platform-specific handling.


Pattern 9: BLE Permissions

BLE permissions differ significantly across Android versions. iOS requires Info.plist entries.

import { Platform, PermissionsAndroid } from "react-native";

async function requestBlePermissions(): Promise<boolean> {
  if (Platform.OS === "android") {
    const apiLevel = Platform.Version;
    if (apiLevel >= 31) {
      // Android 12+: BLUETOOTH_SCAN + BLUETOOTH_CONNECT
      const results = await PermissionsAndroid.requestMultiple([
        PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
        PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
      ]);
      return Object.values(results).every((r) => r === "granted");
    }
    // Android <12: ACCESS_FINE_LOCATION
    const result = await PermissionsAndroid.request(
      PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION,
    );
    return result === "granted";
  }
  // iOS: permissions handled via Info.plist (NSBluetoothAlwaysUsageDescription)
  return true;
}

Why good: Android API level checked for correct permission set, Android 12+ uses new Bluetooth permissions, pre-12 falls back to location permission, iOS handled via plist

See reference.md for the full permission matrix across platforms and Android versions.


<decision_framework>

Decision Framework

BLE vs NFC

What kind of hardware interaction?
|
+-> Continuous connection with a peripheral (sensor, wearable)?
|   +-> BLE -- long-lived connection with GATT operations
|
+-> One-tap read/write (tag, card)?
|   +-> NFC -- session-based, tap and go
|
+-> Background monitoring of nearby devices?
|   +-> BLE -- background scanning with state restoration
|
+-> Quick device provisioning (write config to tag)?
    +-> NFC -- write NDEF record, tap target device

BLE Write Method

Which write method?
|
+-> Data MUST arrive reliably?
|   +-> writeCharacteristicWithResponseForService (acknowledged, slower)
|
+-> Speed matters more than reliability?
|   +-> writeCharacteristicWithoutResponseForService (fire-and-forget, faster)
|
+-> Large payload (> MTU)?
    +-> Negotiate higher MTU first, then use write-with-response

NFC Technology Selection

What kind of NFC tag?
|
+-> Standard NDEF content (URL, text, MIME)?
|   +-> NfcTech.Ndef (iOS + Android)
|
+-> ISO 14443-3A tag (raw commands)?
|   +-> NfcTech.NfcA (iOS + Android)
|
+-> Smart card / ISO 7816 (APDU commands)?
|   +-> NfcTech.IsoDep (iOS + Android)
|
+-> Mifare Classic (Android only)?
|   +-> NfcTech.MifareClassic
|
+-> Mifare Ultralight (Android only)?
    +-> NfcTech.MifareUltralight

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • Skipping discoverAllServicesAndCharacteristics() after connecting -- read/write/monitor calls fail silently or throw errors because GATT structure is not cached
  • Not removing BLE subscriptions on cleanup -- leaked subscriptions fire callbacks after component unmount, causing "setState on unmounted component" crashes
  • Forgetting cancelTechnologyRequest() in NFC finally block -- NFC session stays locked, all subsequent NFC operations fail until app restart
  • Creating multiple BleManager instances -- each instance allocates native resources. Create ONE and share it. Call destroy() only once on app teardown.
  • Scanning without UUID filter and without timeout -- scans all devices indefinitely, drains battery rapidly

Medium Priority Issues:

  • Not checking State.PoweredOn before scanning -- scanning while Bluetooth is off throws errors on some devices and does nothing on others
  • Not handling Base64 encoding/decoding for BLE characteristic values -- raw Base64 strings are not human-readable and cannot be compared directly
  • Using writeCharacteristicWithoutResponseForService for critical data -- fire-and-forget may lose data; use write-with-response for reliability
  • Not requesting MTU on Android -- default 23-byte MTU means 20 usable bytes per packet, making large transfers extremely slow
  • Hardcoding service/characteristic UUIDs inline instead of using named constants

Gotchas & Edge Cases:

  • iOS BLE device IDs are random UUIDs, not MAC addresses -- they can change after Bluetooth is toggled or the device restarts. Do not persist iOS device IDs for reconnection; use service UUID scanning instead.
  • Android 12+ changed BLE permissions -- BLUETOOTH_SCAN and BLUETOOTH_CONNECT replaced ACCESS_FINE_LOCATION for BLE scanning (set neverForLocation: true in Expo config if scanning does not need location)
  • Android 14+ defaults MTU to 517 bytes -- requestMTU may be unnecessary on newer Android devices. Check device.mtu after connection.
  • iOS auto-negotiates MTU up to 187 bytes -- calling requestMTU on iOS has no effect
  • onDeviceDisconnected fires once per registration -- you must re-register the listener after each reconnection if you want continued disconnect monitoring
  • NfcTech.NfcB, NfcF, NfcV are Android-only -- iOS has different equivalents (Iso15693IOS, FelicaIOS). Always check platform before requesting a technology.
  • NFC on iOS shows a system scan dialog -- you cannot customize it beyond the alertMessage. On Android, scanning is silent.
  • BLE allowDuplicates is iOS-only -- Android always emits duplicates. Deduplicate in your scan callback using a Set of device IDs.
  • HCE (Host Card Emulation) is NOT supported by react-native-nfc-manager -- use a dedicated library like react-native-hce for card emulation
  • NFC getTag() returns the last discovered tag -- if no tag was tapped during the session, it returns the previous tag. Always request technology first.

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md

(You MUST call destroy() on BleManager when deallocating resources -- leaking the manager causes native memory leaks and zombie listeners)

(You MUST call discoverAllServicesAndCharacteristics() after connecting before any read/write/monitor operations -- GATT structure is not available until discovered)

(You MUST call cancelTechnologyRequest() in a finally block after every NFC operation -- failing to release the NFC session blocks subsequent scans)

(You MUST check BLE adapter state (PoweredOn) before scanning -- scanning while powered off or unauthorized throws errors silently on some devices)

(You MUST remove all BLE subscriptions (scan listeners, characteristic monitors, disconnect listeners) on cleanup -- leaked subscriptions cause crashes after component unmount)

Failure to follow these rules will cause native crashes, memory leaks, blocked NFC sessions, and battery drain.

</critical_reminders>

Signals

GitHub stars
24
Forks
8
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
mobile-hardware-ble-nfc
Source
github.com/agents-inc/skills