DEV Community

Cover image for Defending the 16ms Frame: A Performance Budget for React Native TurboModules
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Defending the 16ms Frame: A Performance Budget for React Native TurboModules

I was the first engineering hire at Synapsis Medical Technologies, where I owned the architecture for a HealthTech AI platform from day zero. We were integrating real-time health data from wearables and running HIPAA-aligned RAG pipelines. During a critical phase of our scaling—where I grew the team from zero to 21 engineers in 13 months—we hit a wall.

We were building a clinical dashboard that needed to stream high-frequency biometric data. On the old Bridge architecture, the serialisation overhead was killing us. Frames were dropping, the UI felt "heavy," and our clinical users—doctors who don't have patience for laggy interfaces—started complaining. We had moved to React Native 0.76 to leverage the New Architecture, but simply "using TurboModules" didn't fix the jank.

The problem wasn't the technology; it was the lack of a ceiling. We were treating native calls as "free" when they were actually the most expensive part of our render loop. I had to implement a strict performance budget to stop the team from inadvertently shipping 100ms blocks to the main thread. This moved us from a vague "make it faster" mandate to a measurable 16.6ms frame budget that we defended in every PR.

The Cost of the Invisible Bridge

In the legacy architecture, the Bridge was a bottleneck because of asynchronous JSON serialisation. TurboModules solve this by using JSI (JavaScript Interface), allowing direct C++ communication. However, I’ve found that developers often treat JSI as a license to move massive amounts of data frequently.

If you pass a 5MB clinical dataset over JSI every 100ms, you won't see a "Bridge Busy" warning, but you will see the JavaScript thread stall while the C++ layer handles the memory allocation. In our case, the cost was a two-day release cycle that I eventually cut to four hours by automating the validation of these boundaries. If the codegen didn't match the performance spec, the build failed.

Why JSI isn't a silver bullet

The documentation tells you how to write a Spec file and run node-modules/.bin/react-native codegen. It doesn't tell you that C++ handles memory differently than JavaScript's garbage collector.

When you define a TurboModule, you are creating a synchronous contract. If your native method takes 20ms to execute, your JavaScript thread is frozen for 20ms. You have just dropped a frame. On a 60Hz display, you have 16.6ms to do everything. If your TurboModule takes 10ms, you have 6.6ms left for React rendering, layout, and event handling. That is a razor-thin margin.

The Fix: Implementing a Codegen Performance Budget

This approach ensures that native modules are not just "fast," but "predictably fast."

1. Define the Strict Spec with Primitive Types

Avoid passing generic Object or Array types in your TypeScript specs. Every time JSI has to iterate over a generic object to find a key, you lose microseconds. Be explicit.

In your NativeBiometricScanner.ts:

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  // BAD: getHeartRateData(): Object; 
  // GOOD: Explicit primitives reduce JSI conversion overhead
  getHeartRateData(patientId: string): {
    readonly bpm: number;
    readonly confidence: number;
    readonly timestamp: number;
  };
}

export default TurboModuleRegistry.getEnforced<Spec>('NativeBiometricScanner');
Enter fullscreen mode Exit fullscreen mode

Why: Codegen uses these types to generate C++ structs. Primitives map directly to C++ types (double, bool, std::string). Generic objects require a jsi::Object lookup, which is significantly slower under load.

2. Instrumented Codegen Execution

Don't just run the codegen; wrap it in a script that validates the generated C++ glue code for "fat" signatures. I used a simple grep-based check in our CI/CD pipeline to flag any TurboModule using jsi::Value or jsi::Object where a primitive could have been used.

# Run codegen
node node_modules/react-native/scripts/generate-codegen-artifacts.js \
  --path ./ \
  --outputPath ./generated

# Confirm it worked: Check for forbidden generic types in the generated Header
if grep -q "jsi::Object" ./generated/NativeBiometricScannerSpec.h; then
  echo "Error: Generic Object detected in TurboModule Spec. Use explicit types."
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

3. The 16ms Telemetry Wrapper

You cannot manage what you do not measure. We wrapped our TurboModule calls in a telemetry utility. If a call exceeded 8ms (half our frame budget), it triggered a warning in our staging environment.

const start = performance.now();
const data = NativeBiometricScanner.getHeartRateData('pt-123');
const end = performance.now();

if (end - start > 8) {
  console.warn(`TurboModule Budget Exceeded: getHeartRateData took ${end - start}ms`);
}
Enter fullscreen mode Exit fullscreen mode

The Number: In my experience, a single TurboModule call should ideally take less than 2ms. If you are seeing 8ms+, you are either passing too much data or doing heavy computation on the caller's thread instead of a background thread.

4. Offloading to Background Threads

If the telemetry shows you are over budget, you must move the work. TurboModules are synchronous by default, but you can return a Promise which moves the execution to a native background thread.

In your Objective-C++ or C++ implementation, ensure you aren't blocking RCTQueue. Use dispatch_async or std::thread for anything involving file I/O or complex AI processing (like the RAG pipelines we ran).

What it costs, and when not to do it

This strict approach has a high development cost. Writing explicit specs takes 30% longer than throwing an any type at the problem.

When to skip this:

  • If you are building a CRUD app with low data frequency (e.g., a simple form entry).
  • If your app doesn't target low-end Android devices where JSI overhead is more pronounced.

When it is mandatory:

  • High-frequency data (health, finance, sensors).
  • Complex animations that must stay synced with native state.
  • When scaling a team. Without these guardrails, junior engineers will treat TurboModules like standard JS functions, eventually leading to a "death by a thousand cuts" performance profile that is nearly impossible to debug later.

At your level

Starting out

Stop using any in your TypeScript files immediately. TurboModule codegen relies on your types to build the C++ interface; if your types are vague, your native performance will be too. Focus on learning how TurboModuleRegistry differs from NativeModules.

Working engineer

Implement the telemetry wrapper mentioned in Step 3. You likely have modules that are slow, but you don't know which ones. Identify the top three slowest calls and refactor their specs to use primitives instead of objects.

Senior or staff

You own the architecture. Your job is to prevent the "jank" before it's written. Set up the CI/CD check to fail builds that use generic jsi::Object in TurboModule specs. This enforces a performance-first culture without you having to manually review every line of code.

Lead or director

Performance is a budget, not a feature. If your team is missing release deadlines due to "unexplained lag," it's likely a boundary issue. Allocate the 20% extra time needed for proper codegen specs now to avoid the 200% time cost of a performance refactor six months down the line.

In the interview

The Question: "How do TurboModules improve performance over the legacy Bridge?"

The Weak Answer: "They are faster because they use JSI instead of JSON serialisation, so there's less overhead when calling native code."

Why it's weak: It's a textbook answer. It doesn't show you've actually dealt with the consequences of JSI, such as thread blocking or memory management.

The Strong Answer: "TurboModules leverage JSI to provide synchronous access to native methods, eliminating the asynchronous JSON serialisation bottleneck. However, the real advantage is the type-safety provided by Codegen, which generates C++ structs. A strong implementation avoids jsi::Object in favour of primitives to minimize lookup overhead. The trade-off is that because it's synchronous, a poorly written native method can now block the JavaScript thread and drop frames, which wasn't as direct a risk with the asynchronous Bridge. You have to defend a 16ms frame budget by offloading heavy work to background threads and returning Promises."

The Senior/Staff Follow-up: "How do you handle large data transfers between JS and Native in the New Architecture?"
The "I've done this" Answer: "I wouldn't pass large blobs over JSI if I can avoid it. Even with TurboModules, moving a 10MB string is expensive. I’d prefer to pass a reference to a memory-mapped file or use ArrayBuffer / SharedArrayBuffer to share the memory space between the C++ layer and the JS engine. This avoids the copy-overhead entirely."


Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.

Top comments (0)