Skip to content

Mobile·6 min read

Why Bridge Serialization Forced Our Mobile App Rewrite to Native Swift

Streaming 100Hz BLE telemetry through React Native's legacy bridge saturated the event loop, forcing a complete rewrite to native Swift.

Anatoli NavahrodskiFounder & CEO, GlanitPublished 7 September 2026

What happens when 100Hz BLE traffic hits the React Native bridge?

At 100Hz, a Bluetooth Low Energy (BLE) peripheral emits telemetry packets every 10 milliseconds. On React Native's legacy bridge architecture, every incoming packet travels from CoreBluetooth to JavaScript through JSON serialization, message queuing, and asynchronous C++ bindings. Under a continuous 100Hz stream, queue latency compounds rapidly. JS thread utilization locks at 100%, creating an active processing lag exceeding 1,500ms within four seconds of connecting to the peripheral.

Back in November 2023, when we hooked our biometrics chest strap prototype up to an iPhone 13 Pro, we assumed the JS thread could keep pace with 256-byte packets coming in every 10 milliseconds. CoreBluetooth captured incoming payloads cleanly on an iOS background queue. But once data moved toward the application layer, the bridge pipeline choked on context transfers.

Raw NSData binary payloads captured on the background iOS queue cannot cross directly into JavaScript execution space without undergoing heavy marshalling. The CoreBluetooth delegate triggers a background update, passes the buffer to our iOS module, and converts raw binary into intermediate JSON-compatible structures. From there, the legacy C++ bridge stringifies the dictionary before pushing it onto the asynchronous bridge queue. The JavaScript thread must wait for the next event loop tick to pop the message, run JSON.parse(), and allocate native JS objects inside memory. At 1Hz or 10Hz, this pipeline overhead costs under 0.4ms per tick and stays completely invisible. But when the system budget per packet is hard-capped at 10ms, stringifying binary arrays and queuing cross-context switches accumulates backlog faster than the event loop can clear it.

The event loop queue simply overflows.

How did JavaScript thread starvation wreck our UI rendering?

JavaScript runtimes like Hermes run single-threaded, forcing incoming telemetry ingestion to contend directly with UI layout reconciliation, gesture handlers, and screen animations. As the bridge shoved 100 serialized array payloads per second into the event queue, garbage collection pauses surged from 2ms up to 88ms due to millions of ephemeral allocations. Frame rates crashed from a stable 60 FPS down to 12 FPS, while touch input responses lagged by over 400ms.

Incoming 100Hz BLE messages flooded the event loop with up to 600 array allocations every second across timestamps, raw sensor axes, and wrapper structures.

// Typical React Native BLE bridge message wrapper payload
{
  "sensorId": "ACCEL_01",
  "timestamp": 1698234102911,
  "payload": [0.012, -0.981, 0.104, 0.005, 0.011, -0.002, 102.4, 98.6]
}

Every incoming payload instantiated brand new JavaScript arrays and object dictionaries. Hermes is optimized for instant application startup and a lean memory footprint, but pumping thousands of temporary floating-point array objects per minute triggers frequent compaction sweeps. The runtime freezes execution while clearing dead memory, which drops frames right in the middle of live chart renders. Gesture handlers for navigation drawer drags stalled midway through user swipes because the engine prioritized parsing raw array packets over layout calculations.

Can bridge architectures compete with native Swift under heavy load?

Benchmarking data transport layers under 100Hz telemetry requires measuring ingestion latency, CPU load, heap allocation rate, and UI frame stability. The legacy asynchronous bridge breaks down under high packet volume because of queueing overhead. While the JavaScript Interface (JSI) removes JSON string conversions, it still binds processing directly to the single-threaded JS event loop. Native Swift with AsyncStreams parses raw binary telemetry on background utility threads with zero memory copies.

Performance Benchmarks Across Architectures at 100Hz (10ms Packet Interval)
MetricRN Async BridgeRN JSI / TurboModulesNative Swift (AsyncStream)
Average Ingestion Latency1,240ms – 2,410ms (Queued)18ms – 42ms0.8ms – 1.4ms
CPU Overhead at 100Hz88% – 100% (JS Core)45% – 62% (JS Core)4% – 7% (Background)
Memory Allocations / Min~118 MB (Transient)~34 MB (Transient)< 2 MB (Zero-copy Structs)
UI Frame Rate (Target 60 FPS)12 – 16 FPS (Unusable)42 – 48 FPS (Jittery)60.0 FPS (Stable)
Buffer Overflow RiskHigh queue accumulationModerate JS thread locksZero (Handled by backpressure)
Performance Benchmarks Across Architectures at 100Hz (10ms Packet Interval)

Why weren't JSI and TurboModules enough to handle 100Hz telemetry?

React Native's JavaScript Interface (JSI) and TurboModules eliminate legacy JSON bridge serialization by exposing C++ host objects directly to JavaScript, yet they fail to solve single-threaded runtime bottlenecks. Calling synchronous JSI host functions 100 times per second stalls the main JS thread, blocking UI updates and gesture recognition. Converting raw C++ byte arrays into JS TypedArrays fragments heap memory, triggering garbage collection sweeps that halt real-time UI charts.

In December 2023, we spent three full weeks prototyping custom C++ TurboModules and JSI bindings in hopes of avoiding a complete native rewrite. The JSI setup let us pass raw C++ pointers straight to JavaScript using jsi::ArrayBuffer without touching string serialization.

// JSI C++ binding attempting high-frequency buffer sharing
jsi::Value BindingModule::getTelemetryPacket(jsi::Runtime& runtime) {
  uint8_t* rawBuffer = getLatestBLEBuffer();
  auto arrayBuffer = runtime.global()
    .getPropertyAsFunction(runtime, "ArrayBuffer")
    .callAsConstructor(runtime, 256);
  memcpy(arrayBuffer.getObject(runtime).getArrayBuffer(runtime).data(runtime), rawBuffer, 256);
  return arrayBuffer;
}

Thread pinning stopped us in our tracks. Hermes JSI operations must execute inside the single JS runtime execution context, meaning every buffer copy required grabbing a thread lock or executing synchronously on the JavaScript thread. At 100 packets per second, the engine spent all its time unrolling raw data buffers, completely blocking React from processing touch events or recalculating screen layouts. JSI shaved latency down from 1,500ms to about 30ms, but stuttering frames remained because data processing was still tethered to the JS thread.

How do Swift AsyncStreams resolve real-time frame drops?

Rebuilding our telemetry engine in native Swift eliminated frame drops by decoupling data ingestion entirely from the UI thread using Swift Concurrency and custom AsyncStream pipelines. CoreBluetooth sends raw binary buffers directly to a background Swift actor, where payloads are parsed zero-copy using memory layout casting. Telemetry points accumulate in sliding ring buffers and render through Metal-backed SwiftUI charts, driving end-to-end processing latency down from 1,500ms to a deterministic 1.2ms.

We organized our streaming pipeline into explicit isolation zones:

  • Ingestion Zone: CoreBluetooth captures raw Data buffers on a utility queue. The TelemetryEngine background actor processes incoming packets without locking user interface state.
  • Zero-Copy Parsing: Binary payloads map directly to Swift stack structures via withUnsafeBytes, bypassing dynamic heap allocations.
  • Display Zone: A timer flushes accumulated state to SwiftUI charts at 60Hz, keeping rendering in sync with the physical screen refresh cycle.
// Native Swift actor handling 100Hz raw binary BLE stream
actor TelemetryEngine {
    private var ringBuffer = FixedSizeBuffer<SensorPacket>(capacity: 1000)
    
    func ingest(rawData: Data) {
        rawData.withUnsafeBytes { ptr in
            guard let baseAddress = ptr.baseAddress else { return }
            let packet = baseAddress.assumingMemoryBound(to: SensorPacket.self).pointee
            ringBuffer.append(packet)
        }
    }
}

This structure decouples data ingestion rates from interface rendering speeds. If hardware specs scale up to 500Hz in the future, the Swift actor will handle byte parsing on background cores without interfering with UI rendering.

When should mobile teams abandon cross-platform frameworks for native code?

Choosing between cross-platform frameworks and native development depends heavily on telemetry throughput, thread isolation needs, and allocation churn. When an app demands continuous processing of sensor streams above 30Hz, zero-copy binary parsing, or deterministic frame updates, JavaScript runtimes introduce systemic performance bottlenecks. Auditing pipeline throughput early prevents expensive mid-project migrations to native codebases.

Before picking React Native or Flutter for IoT, robotics, or medical devices, audit your stack against these technical triggers:

  • Packet ingestion exceeding 30Hz: High-frequency incoming data quickly drains single-threaded event loops.
  • Strict frame budgets under 16ms: If incoming telemetry directly drives real-time chart renderings, off-main-thread execution is non-negotiable.
  • Zero-copy binary protocols: Processing packed C structs requires raw pointer casting, which cannot be expressed cleanly inside managed JavaScript heaps.

Dropping React Native for Swift brought our CPU usage down from 92% to 6%, cut RAM consumption from 180MB to 22MB, and restored solid 60 FPS rendering across every iOS test device.

Frequently asked questions