Field apps are where Ionic quietly wins. A technician with a phone, a Bluetooth sensor in one hand and an NFC-tagged asset in the other does not care what your UI is built in — they care that the connection holds and the scan works with gloves on. Every quarter we get asked some version of the same question: can a Capacitor app really talk to our hardware, or do we need native?
The answer is almost always yes, it can. Bluetooth Low Energy and NFC are both reachable from a Capacitor app through plugins that wrap the platform APIs directly, and the result is indistinguishable from native at the point where it matters — the radio. What trips teams up is never the API call. It is permissions, background behaviour, the fact that BLE state on Android and iOS disagree about almost everything, and writing a connection layer that survives a user walking out of range mid-write.
This tutorial builds a working BLE + NFC layer for an Ionic app: permissions that pass store review, a scan-and-connect flow, a resilient GATT wrapper with retries and queued writes, NFC tag reading on both platforms, and the test plan we use before we let any of it ship. Examples are Ionic Angular, but the service layer is plain TypeScript and drops into React or Vue unchanged.
What you need
npm i @capacitor-community/bluetooth-le
npm i @capawesome-team/capacitor-nfc
npx cap sync
@capacitor-community/bluetooth-le wraps CoreBluetooth on iOS and the
Android BLE stack; it is the plugin we reach for on most engagements because
it exposes notifications, MTU and a usable connection-state callback. For NFC
there are several options — the important part is that whatever you pick
supports NDEF reading on both platforms and exposes the iOS scan-session
lifecycle, because iOS NFC is session-based and Android is not.
1. Permissions, the part that fails review
Get this wrong and you get either a silent no-op or a rejection. Both platforms tightened Bluetooth permission strings and Android split them by purpose.
iOS — Info.plist
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Example connects to your Example Sensor to read temperature and battery readings.</string>
<key>NFCReaderUsageDescription</key>
<string>Example scans the NFC tag on equipment to open its service record.</string>
Be specific. "This app uses Bluetooth" is the string that gets flagged; naming the hardware and what you read from it is the string that passes. NFC additionally requires the Near Field Communication Tag Reading capability on the App ID and in the Xcode target, and the entitlement:
<key>com.apple.developer.nfc.readersession.formats</key>
<array><string>NDEF</string></array>
If you forget the capability, NfcManager.isSupported() returns true and the
scan session simply never fires. That symptom costs people a full afternoon.
Android — AndroidManifest.xml
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Only if you scan for beacons / derive position from BLE -->
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> -->
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
The neverForLocation flag matters commercially, not just technically. If
you declare BLUETOOTH_SCAN without it you inherit location-permission
prompts and a Play Data Safety disclosure about location data you are not
actually collecting. Declare neverForLocation unless you genuinely derive
position from beacons.
Keep android:required="false" on the NFC feature or you disappear from the
Play listing for every device without an NFC chip.
2. Initialising and asking at the right moment
import { BleClient } from '@capacitor-community/bluetooth-le';
export async function initBle(): Promise<void> {
// Shows the system enable prompt on Android if the radio is off.
await BleClient.initialize({ androidNeverForLocation: true });
}
Call this when the user taps "Connect a sensor", never on app start. A Bluetooth permission dialog on first launch, before the user knows what the app does, is the single biggest driver of permanent denials we see in analytics. Same rule as push notifications: earn the prompt.
Always handle the denied path explicitly rather than leaving a dead button:
try {
await initBle();
} catch (e) {
// User declined, or the radio is off and they dismissed the prompt.
this.state = 'ble-unavailable';
return;
}
3. Scan and connect
Filter by service UUID. An unfiltered scan on a busy floor returns dozens of devices per second, burns battery, and on iOS will not give you the device name you expect anyway.
import { BleClient, ScanResult } from '@capacitor-community/bluetooth-le';
const SENSOR_SERVICE = '0000180a-0000-1000-8000-00805f9b34fb';
export async function scanForSensors(
onDevice: (r: ScanResult) => void,
ms = 8000,
): Promise<void> {
const seen = new Set<string>();
await BleClient.requestLEScan({ services: [SENSOR_SERVICE] }, (result) => {
if (seen.has(result.device.deviceId)) return; // dedupe: adverts repeat
seen.add(result.device.deviceId);
onDevice(result);
});
setTimeout(() => BleClient.stopLEScan(), ms);
}
Two platform facts worth internalising before you design the UI:
deviceIdis not stable across platforms. On Android it is the MAC address and it persists. On iOS it is a per-installation UUID that CoreBluetooth assigns, and it changes if the user reinstalls the app. Never key your backend records on it. Store your own identifier — a serial number read from a GATT characteristic after connecting — and treatdeviceIdas a session-scoped handle.- Reconnecting is not re-scanning. Once you have connected to a device and stored its id, connect straight to it. Forcing a scan every time makes a two-second reconnect feel like ten.
export async function connect(deviceId: string, onDisconnect: (id: string) => void) {
await BleClient.connect(deviceId, onDisconnect, { timeout: 10_000 });
await BleClient.getServices(deviceId); // discovery; required before I/O on Android
}
4. A GATT layer that survives the real world
This is the part teams skip and then rewrite three weeks later. Radios drop. Users pocket the phone. Android's stack will happily return error 133 for no articulable reason and succeed on the retry. Two things fix nearly all of it: serialise your operations, and retry with backoff.
type Op<T> = () => Promise<T>;
export class GattQueue {
private chain: Promise<unknown> = Promise.resolve();
/** Serialises every GATT op — concurrent reads/writes are a top crash source. */
run<T>(op: Op<T>): Promise<T> {
const next = this.chain.then(() => this.withRetry(op));
this.chain = next.catch(() => undefined); // never poison the chain
return next;
}
private async withRetry<T>(op: Op<T>, attempts = 3): Promise<T> {
let lastErr: unknown;
for (let i = 0; i < attempts; i++) {
try {
return await op();
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 150 * 2 ** i)); // 150, 300, 600ms
}
}
throw lastErr;
}
}
Now the readable device service:
import { BleClient, numbersToDataView, dataViewToText } from '@capacitor-community/bluetooth-le';
const BATTERY_SERVICE = '0000180f-0000-1000-8000-00805f9b34fb';
const BATTERY_LEVEL = '00002a19-0000-1000-8000-00805f9b34fb';
export class SensorService {
private q = new GattQueue();
constructor(private deviceId: string) {}
readBattery(): Promise<number> {
return this.q.run(async () => {
const v = await BleClient.read(this.deviceId, BATTERY_SERVICE, BATTERY_LEVEL);
return v.getUint8(0); // 0–100
});
}
setSampleRate(hz: number): Promise<void> {
return this.q.run(() =>
BleClient.write(this.deviceId, SENSOR_SERVICE, CONFIG_CHAR, numbersToDataView([hz])),
);
}
async streamReadings(onValue: (n: number) => void): Promise<void> {
await this.q.run(() =>
BleClient.startNotifications(this.deviceId, SENSOR_SERVICE, DATA_CHAR, (value) => {
onValue(value.getInt16(0, /* littleEndian */ true));
}),
);
}
}
Notifications are the right primitive for streaming data. Polling a
characteristic in a setInterval works in the office and destroys battery in
the field; it also collides with your own writes, which is exactly what the
queue above exists to prevent.
Two more things that pay for themselves:
// Android only, ignored elsewhere. Default MTU is 23 bytes = 20 bytes of payload.
await BleClient.requestMtu(deviceId, 185);
// Treat disconnect as a state transition, not an error.
await BleClient.connect(deviceId, (id) => {
this.zone.run(() => this.store.markDisconnected(id)); // Angular: back into the zone
});
That zone.run is an Ionic-specific gotcha. Plugin callbacks arrive outside
Angular's zone, so your UI updates but does not re-render. In React the
equivalent trap is calling setState from a listener you registered in a
useEffect that has already cleaned up — always return the unsubscribe.
5. Backgrounding
The single most common misconception: BLE does not keep working the same way when the app is backgrounded.
- iOS suspends your app quickly. To keep a connection alive you need the
bluetooth-centralbackground mode inUIBackgroundModes, and even then you only get delivery of notifications for characteristics you subscribed to before backgrounding. Scanning in the background is heavily throttled and service-UUID filters become mandatory. - Android kills long-lived connections under Doze unless the work runs in a foreground service with a visible notification.
If your product requires continuous background capture, budget for a small native layer — a foreground service on Android, and careful state restoration on iOS. Our post on background work in Ionic apps covers the sync-queue side of this. Design around it where you can: buffer on the device, sync on foreground.
6. NFC
NFC is far simpler than BLE, with one structural difference between the platforms you must design for.
iOS is session-based. The user taps a button, the system shows a sheet, they hold the phone to the tag, the session ends. You cannot scan passively in the background from your own UI.
Android scans ambiently. Bring a tag near an unlocked device and the OS dispatches the intent, and it can launch your app directly if you register an intent filter for the tag's URI.
import { Nfc, NfcTagScannedEvent } from '@capawesome-team/capacitor-nfc';
export async function scanTag(): Promise<string | null> {
const { nfc } = await Nfc.isAvailable();
if (!nfc) return null;
return new Promise(async (resolve) => {
const handle = await Nfc.addListener('nfcTagScanned', async (ev: NfcTagScannedEvent) => {
const record = ev.nfcTag.records?.[0];
const text = record ? decodeNdefText(record.payload) : null;
await handle.remove();
await Nfc.stopScanSession();
resolve(text);
});
// iOS: shows the system scan sheet. Android: starts the reader mode.
await Nfc.startScanSession();
});
}
function decodeNdefText(payload: number[]): string {
// NDEF text records: first byte is a status byte; low 6 bits = language code length.
const langLen = payload[0] & 0x3f;
return new TextDecoder().decode(new Uint8Array(payload.slice(1 + langLen)));
}
Always remove the listener and stop the session when you are done. A leaked listener on Android means the next tag scan fires a handler belonging to a page the user left, and on iOS the scan sheet reappears at a moment that looks, to the user, like a bug.
For asset-tracking apps, put a URL record on the tag rather than a bare id. A URL tag scanned by a phone that does not have your app installed opens the web version instead of failing silently — and on Android an intent filter on that host takes the user straight into the app. One tag format, two good outcomes.
7. Testing hardware code
You cannot test any of this in a browser, and you should not need physical hardware to run your CI.
Abstract the plugin behind an interface. Everything above lives in
SensorService; your components depend on that, never on BleClient
directly. Now a fake implementation replays a recorded stream of readings and
your component tests run in Playwright on the web build with no radio in
sight.
export class FakeSensorService implements Sensor {
async readBattery() { return 72; }
async streamReadings(cb: (n: number) => void) {
let i = 0;
setInterval(() => cb(200 + Math.round(Math.sin(i++ / 5) * 30)), 250);
}
}
Then test the things only hardware reveals. Our device checklist before release:
- Walk out of range mid-write. Does the UI say "disconnected" or spin forever?
- Turn the radio off while connected, then back on. Does it recover without a restart?
- Background the app for five minutes, return. Is the connection alive, or cleanly re-established?
- Deny the Bluetooth permission, then grant it in Settings and return. No restart required?
- Two phones, one sensor. Does the second get a clear "busy" state?
- NFC: scan the wrong tag type. Scan with a case on. Cancel the iOS sheet mid-scan.
- Airplane mode with a queued write pending.
- Low battery mode on iOS — scanning is throttled hard.
Numbers 1, 3 and 4 are where most first-cut implementations fail, and none of them show up in a demo on a desk.
Where this leaves you
A Capacitor app can talk to BLE peripherals and NFC tags with full native capability. The work is not in the radio calls — it is in the permission strings, the serialised GATT queue, the disconnect handling and an honest position on backgrounding. Build those four and hardware features stop being the risky part of the roadmap.
If you are scoping a connected-hardware app on Ionic and want a second opinion on whether a particular peripheral or background requirement is a good fit — or you need senior Ionic developers who have shipped this before — get in touch. We have built field-service, medical-device companion and asset-tracking apps on this stack, and we will tell you plainly when a requirement genuinely needs native.