Building Reliable BLE Firmware Updates in React Native

Building Reliable BLE Firmware Updates in React Native

Dec 12, 2025
13 minute read

A firmware update can turn a good IoT app into a trusted product, or into a support nightmare. With React Native BLE updates, the hard part isn't the progress bar. It's getting the device through disconnects, reboots, packet loss, and post-flash checks without leaving it in a bad state.

Most teams hit the same wall. Generic BLE reads and writes work in JavaScript, then OTA updates start failing on one Android model, or only after the device switches into bootloader mode.

The fix is to treat firmware delivery as a transport problem first, and a React Native feature second.

Start with an architecture that won't fight BLE

For production apps in 2026, the safest design is a hybrid stack. Keep screens, session state, analytics, and business logic in React Native. Move the firmware transfer, reconnect logic, and platform-specific BLE work into native iOS and Android modules.

That split keeps JavaScript out of the most timing-sensitive path. It also gives you access to native APIs that matter during DFU, such as Android MTU negotiation, connection priority, and iOS write pacing.

Keep the update session native, then expose a small bridge API to React Native. That removes most timing and app lifecycle issues before they show up in the field.

A clean bridge usually needs only a few methods and events. For example, React Native can call prepareUpdate, startUpdate, cancelUpdate, and resumeUpdate. Native code can emit stateChanged, progress, warning, and error.

!A sleek smartphone sits atop a polished wooden surface next to a compact cylindrical sensor. The arrangement highlights a clean, professional workstation environment designed for modern technical hardware maintenance and configuration.

If you support Expo, plan for a development build or config plugin as soon as DFU enters the roadmap. A JS-only managed app is rarely enough for robust OTA. For Nordic devices in Expo-based apps, the Expo Nordic DFU package is a practical shortcut.

This comparison helps when picking the update path:

| Approach | Works well when | Main risk | | -------------------------------------- | ----------------------------------- | -------------------------------------------------- | | JS-only BLE transfer | Prototype apps and simple lab tools | Weak timing control, suspend issues, poor recovery | | React Native UI with native DFU bridge | Most production IoT apps | More setup in iOS and Android | | Vendor SDK wrapper | Device uses a vendor bootloader | SDK constraints and vendor lock-in |

Most teams should land in the middle row. You still get a shared app codebase, but the BLE session runs where the operating system gives you the most control.

A state machine also helps. Model phases like idle -> preflight -> rebooting -> bootloaderScan -> transferring -> validating -> reconnecting -> verified. Persist the current phase and session ID so a crash or app restart doesn't leave the UI guessing.

The end-to-end firmware update flow

Reliable updates follow a strict sequence. If you skip preflight checks or blur phase boundaries, the device may still flash, but your failure rate will rise fast.

Preflight, scanning, and capability checks

Start in application mode, not bootloader mode. Scan for your normal device advertisement, connect, and discover services and characteristics. At this point, you should read the values that decide whether the update is even allowed.

That usually includes firmware version, hardware revision, battery level, bootloader version, and a stable device ID. If your device uses signed update packages, read the device model and package target before transfer begins. Don't trust a file name.

For generic BLE work, many teams use react-native-ble-plx or react-native-ble-manager for scan and control-plane reads. That's fine. The update stream still belongs in native code once the device enters DFU mode.

Preflight should also validate the file itself. Parse the manifest, check size, expected CRC or hash, minimum bootloader version, and anti-rollback rules. If the device is too low on battery, stop early. If the package targets another hardware SKU, stop early. If the app lacks permissions, stop early.

On Android, request the permissions you need before scan time, not halfway through the session. Current Android versions use BLUETOOTH_SCAN and BLUETOOTH_CONNECT for most BLE flows. On iOS, your Bluetooth usage strings need to be in place before App Store review and before the first real device test.

Try to separate the control plane from the data plane in your protocol. A common layout uses one characteristic for commands, one for firmware bytes, and one for notifications or status. If you only have one characteristic, add packet headers with an opcode, sequence number, and length. Without that metadata, debugging transfer issues gets painful fast.

Switching the device into bootloader or DFU mode

Once preflight passes, tell the application firmware to reboot into update mode. This usually happens through a control characteristic, though some devices use a vendor command over the main service.

Expect a disconnect. That's normal.

The mistake is treating that disconnect like a failure. In most update flows, it's the signal that phase one worked. Native code should mark the session as rebooting, start a timer, and scan for the bootloader advertisement.

This is where identity problems show up. A bootloader may advertise a different name, address, or service UUID than the main app firmware. Android may show a new MAC-like address on some hardware. iOS may hand you a different CBPeripheral identity after reboot. Because of that, match bootloader targets using the most stable data you can get, such as manufacturer data, a serial in service data, or a known DFU service UUID tied to the same physical device.

If your device bonds in application mode, don't assume the bond survives the switch. Some bootloaders keep encryption. Others don't. Test both fresh-pair and bonded paths.

Set a separate timeout for this phase. A failed reboot and a slow transfer are different problems, so they need different error messages and retry logic.

Chunked transfer, acknowledgements, and pacing

The transfer phase is where many custom implementations fall apart. BLE isn't a file socket. You need pacing, chunk sizing, and a way to detect loss or corruption.

First, pick the right chunk size. On Android, request a larger MTU when the connection is up, often 247 if your peripheral supports it. Your usable payload is the negotiated MTU minus protocol overhead. On iOS, the stack negotiates automatically, and the value that matters is CoreBluetooth's maximum write length for the write type you use.

Second, choose your write mode carefully. writeWithResponse is slower but simple. Every packet gets a callback, which makes ordering easy. writeWithoutResponse is much faster, but you need your own backpressure and ACK strategy. On iOS, respect canSendWriteWithoutResponse and wait for peripheralIsReady(toSendWriteWithoutResponse:). On Android, pace no-response writes yourself or use a native queue.

Most production protocols use a windowed approach. Send N packets without response, then ask the device to ACK the highest contiguous sequence number, current offset, or rolling CRC. If the ACK stalls, resend from the last confirmed offset. That gives you better throughput than response-per-packet, while still giving the app a recovery point.

A solid packet format often includes:

  1. A sequence number or byte offset.
  2. Payload length and payload bytes.
  3. Optional per-packet CRC for early corruption detection.
  4. A transfer-level checksum or hash validated at the end.

If the bootloader supports resume, use it. After reconnect, ask for the current offset and expected CRC, then continue from there. If it doesn't, restart cleanly and say so in the UI. Silent partial resumes are dangerous.

Keep your React Native layer informed, but don't let it drive each packet. Native code should own the queue, current offset, retry counters, and transfer clock. JavaScript should receive coarse progress updates, such as every 250 ms or every 1 percent. That keeps the bridge quiet and the UI smooth.

Integrity checks, reboot, and post-update verification

A progress bar reaching 100 percent does not mean the update finished. It only means the phone sent the bytes.

The device still has to validate the image, write flash, reboot, and report the new firmware version.

This phase should include both integrity and authenticity checks. Integrity confirms the bytes arrived intact, often through CRC32 or a hash. Authenticity confirms the image is allowed to run, often through a signature and key stored in the bootloader.

After transfer ends, send a finalize command if your protocol requires one. The bootloader validates the package, flashes it, and reboots into application mode. Then the app scans again, reconnects, and verifies the new version, build ID, or image hash through a normal characteristic read.

Treat that final reconnect as part of the update, not as a separate convenience step. If the app never confirms the new firmware, the session isn't done.

A good post-update check also reads a basic health signal, such as uptime reset, a status flag, or a successful command response from the new firmware. That helps catch cases where the version string changed but the device is stuck in a degraded state.

iOS and Android differences that matter

Cross-platform BLE is never fully symmetrical. The high-level flow is the same, but the rough edges are different.

Android gives you more knobs, and more ways to break

Android lets you tune more connection details. You can request MTU, raise connection priority, and run a foreground service for long transfers. Those controls help a lot during DFU.

They also add failure modes. Service discovery can go stale after the device reboots into another firmware image. Reconnect with a fresh GATT session after the mode switch. Don't try to reuse the old service map. Also, don't assume scan callbacks behave the same across vendors. A Samsung phone, a Pixel, and a rugged enterprise device can act differently under power-saving rules.

For long updates, a foreground service is worth the extra work. It lowers the odds that the OS throttles your session while the screen is off. In addition, log the phone model and Android version with every failure report. Patterns often show up by device family.

iOS is more predictable, but tighter

CoreBluetooth is more consistent across devices, yet it gives you fewer low-level controls. You don't request MTU directly, so chunk sizing must follow the values the peripheral reports through CoreBluetooth APIs. If you ignore those limits, writes will stall or fail.

No-response writes need care on iOS. When the system says it can't accept more data, pause. Resume only after the peripheral is ready again. That one rule fixes a surprising number of "random" stalls.

Background behavior is another difference. iOS can reconnect BLE peripherals under some conditions, but long DFU sessions are much safer while the app stays active in the foreground. Keep the screen awake during the transfer, warn the user not to switch apps, and test interruption cases like calls, lock screen, and low battery mode.

Across both platforms, build the update code around phase-specific recovery. A timeout during bootloader scan is not the same as a bad CRC at 87 percent. Your logs and UI should make that clear.

When vendor DFU libraries beat a custom implementation

If your device uses a standard vendor bootloader, don't rebuild the protocol unless you have a strong reason. A vendor SDK already knows the packet layout, mode switch rules, resume behavior, and validation steps. That's a lot of edge cases you don't need to relearn.

Nordic is the clearest example. If your hardware uses Secure DFU, the shortest path is wrapping the vendor libraries rather than writing a raw BLE uploader. On Android, use the Nordic Android DFU Library. On iOS, use iOSDFULibrary on CocoaPods.

Those libraries already handle the details that usually cause field failures, such as bootloader discovery, packet receipt notifications, and package validation. Your React Native bridge can stay small and focused on file selection, session state, progress, cancellation, and post-update verification.

A custom protocol still makes sense when you own the full device stack and need features a vendor SDK doesn't fit. Common reasons include strict product security rules, a dual-bank bootloader with custom rollback logic, or a need to update extra partitions through one app flow.

When you go custom, document the protocol like an API. Define the control opcodes, chunk sizes, ACK semantics, timeout rules, bootloader advertisement format, and recovery behavior. Without that contract, the mobile and firmware teams will drift, and update bugs will bounce between both sides for months.

Failure handling and BLE troubleshooting in production

Field reliability comes from the boring parts: retries, logs, and clear stop conditions.

Start with retry policy. Use short, bounded retries for connect and scan phases. Use offset-based resume during transfer if the bootloader supports it. If validation fails, stop and surface the exact reason. Retrying a bad signature or wrong image wastes time and battery.

Telemetry matters as much as code. Log the session ID, device model, current and target firmware, battery level, RSSI at start, MTU or maximum write length, chunk size, ACK window, bytes confirmed, retry count, phone model, OS version, and final error code. Send that to your backend when the session ends. Support teams can work with that data. "Update failed" tells nobody anything.

These failure patterns show up often:

  • The app matches the wrong peripheral after reboot because bootloader advertising changed.
  • The sender pushes no-response writes faster than the device can drain them.
  • The update starts on low battery and dies during flash.
  • Android reconnects to a cached GATT state after mode switch.
  • The UI marks success at 100 percent transfer, before validation and reboot finish.
  • The device accepts the file, then rejects it because the manifest targets another hardware revision.

Build your UI around those realities. Use phase labels like "Preparing device", "Switching to update mode", "Sending firmware", and "Verifying installation". Users tolerate a slow update better than a vague one.

Cancellation also needs rules. If the user cancels before flashing starts, stop and disconnect. If the device is in the middle of writing flash, block cancellation and explain why. A half-stopped update is worse than a short wait.

Finally, test the ugly paths on real hardware. Force Bluetooth off mid-transfer. Walk out of range. Lock the phone. Trigger a call. Use a firmware file with a bad signature. Reboot the device during validation. Those cases define whether your BLE update flow is dependable or only lucky in the lab.

Conclusion

Reliable firmware delivery in React Native comes from one main decision: keep the UI cross-platform, but keep the BLE update engine native. That split gives you better pacing, better recovery, and fewer platform surprises.

Once that foundation is in place, the rest is discipline. Run preflight checks, handle the bootloader switch as its own phase, send chunked data with ACKs, verify the image after reboot, and log enough detail to debug failures in the field.

When the device uses a proven vendor bootloader, wrap the SDK and move on. When you own the whole stack, treat the update protocol like a product feature, because your users will notice every weak spot.