DEV Community

Cover image for Synchronous TurboModules Are Not Automatically Fast
Subrata Kumar Das
Subrata Kumar Das

Posted on

Synchronous TurboModules Are Not Automatically Fast

React Native’s New Architecture gives JavaScript more direct access to native functionality.

One important capability is synchronous communication through TurboModules.

That can be useful.

It can also introduce a performance problem that is easy to miss:

A native method being synchronous does not mean the native work is fast.

The communication mechanism may be efficient, but the JavaScript thread must still wait for the native operation to finish.

That difference matters in production applications.


The misleading simplicity of a synchronous API

Consider a native method exposed like this:

const token = SecureStorage.getAccessToken();
Enter fullscreen mode Exit fullscreen mode

From the JavaScript side, it looks harmless.

There is no await, no callback and no visible asynchronous workflow.

The value is returned immediately from the developer’s point of view.

But the native implementation may perform several operations before returning:

  • access Android Keystore or iOS Keychain
  • decrypt stored data
  • read from disk
  • parse JSON
  • convert native objects into JavaScript-compatible values
  • communicate with another system service

If this work takes time, JavaScript cannot continue until the method returns.

The API looks simple.

The execution may not be.


What synchronous actually means

A synchronous call means the caller waits for the result.

For example:

const result = NativeModule.getValue();

renderScreen(result);
Enter fullscreen mode Exit fullscreen mode

renderScreen cannot begin until getValue() finishes.

That behaviour is acceptable when the native operation is extremely small and predictable.

It becomes dangerous when the operation has variable latency.

The same method may take:

  • less than one millisecond when data is cached
  • several milliseconds when data requires conversion
  • much longer when storage, cryptography or system services are involved

The JavaScript thread does not care why the native method is delayed.

It simply remains blocked.


Why this affects React Native performance

React Native applications perform important work on the JavaScript thread.

This can include:

  • processing state updates
  • running application logic
  • handling user interactions
  • preparing React renders
  • coordinating navigation
  • executing animation-related JavaScript
  • responding to events from native modules

When a synchronous native method blocks that thread, other JavaScript work must wait.

A single call may not create an obvious problem.

Repeated calls can.

Imagine that a component reads a secure value during rendering:

function AccountScreen() {
  const token = SecureStorage.getAccessToken();

  return <AccountDetails token={token} />;
}
Enter fullscreen mode Exit fullscreen mode

Now imagine that the component renders multiple times during navigation or state updates.

Every render may trigger another native operation.

If the method accesses storage and performs decryption, the application is repeatedly blocking the JavaScript thread inside the render path.

The visible result may be:

  • slow navigation
  • delayed button responses
  • dropped animation frames
  • inconsistent screen transitions
  • temporary UI freezes
  • performance that becomes worse on lower-end devices

The developer may initially investigate React re-renders.

The real problem is the synchronous native work performed during those renders.


TurboModules reduce overhead, not execution cost

This is the central distinction.

TurboModules improve how JavaScript and native code communicate.

They can reduce some of the overhead associated with the older bridge architecture.

But TurboModules do not make the native operation itself free.

Suppose a native method performs three tasks:

Read secure storage
        ↓
Decrypt the value
        ↓
Convert it into a JavaScript object
Enter fullscreen mode Exit fullscreen mode

The communication layer may be efficient.

The application must still pay for:

  • storage access
  • cryptographic processing
  • object allocation
  • data conversion
  • thread coordination

Optimising the path between JavaScript and native code does not eliminate the work happening at the destination.

A faster doorway does not make the room behind it smaller.


When synchronous native methods are appropriate

Synchronous methods are useful when the operation is:

  • tiny
  • deterministic
  • memory-resident
  • frequently required
  • guaranteed not to perform slow I/O
  • predictable across supported devices

Examples may include reading a cached capability:

const supportsBiometrics = DeviceCapabilities.hasBiometricHardware();
Enter fullscreen mode Exit fullscreen mode

Or checking a native constant:

const environment = AppConfiguration.getEnvironment();
Enter fullscreen mode Exit fullscreen mode

Or performing a very small calculation:

const pixels = DisplayUtils.dpToPixels(16);
Enter fullscreen mode Exit fullscreen mode

These operations are generally suitable only when their native implementation remains lightweight.

The important question is not whether the return type is small.

The important question is how the value is produced.

Returning one boolean can still be expensive if the implementation queries a system service every time.


Operations that should usually remain asynchronous

Native methods should normally be asynchronous when they involve:

Storage

const profile = await SecureStorage.getProfile();
Enter fullscreen mode Exit fullscreen mode

Disk and secure-storage access may have unpredictable latency.

Cryptography

const decryptedPayload = await CryptoModule.decrypt(payload);
Enter fullscreen mode Exit fullscreen mode

Cryptographic work can become expensive depending on the algorithm and input size.

Networking

const response = await NativeNetworkClient.fetchConfiguration();
Enter fullscreen mode Exit fullscreen mode

Network operations are naturally unpredictable and should never block JavaScript synchronously.

Large data conversion

const records = await NativeDatabase.getRecords();
Enter fullscreen mode Exit fullscreen mode

Converting thousands of native records into JavaScript objects can consume considerable time.

Image and file processing

const result = await ImageProcessor.compress(filePath);
Enter fullscreen mode Exit fullscreen mode

Media processing is computationally expensive and should normally run away from latency-sensitive application work.

System services

const location = await LocationModule.getCurrentPosition();
Enter fullscreen mode Exit fullscreen mode

The response time may depend on permissions, sensors, device state and operating-system behaviour.


Avoid synchronous work inside rendering

One of the strongest review rules is:

Do not perform synchronous native work inside a React render path unless the operation is proven to be trivial.

This is risky:

function Dashboard() {
  const settings = NativeSettings.getSettings();

  return <DashboardContent settings={settings} />;
}
Enter fullscreen mode Exit fullscreen mode

A better design is to load the value asynchronously and store it in application state:

function Dashboard() {
  const [settings, setSettings] = useState<Settings | null>(null);

  useEffect(() => {
    let active = true;

    async function loadSettings() {
      const value = await NativeSettings.getSettings();

      if (active) {
        setSettings(value);
      }
    }

    loadSettings();

    return () => {
      active = false;
    };
  }, []);

  if (!settings) {
    return <LoadingScreen />;
  }

  return <DashboardContent settings={settings} />;
}
Enter fullscreen mode Exit fullscreen mode

The exact state-management approach may differ.

The principle remains the same:

Do not make rendering wait for native storage, cryptography or system resources.


Cache deliberately, not accidentally

Sometimes a synchronous API is created because the application needs immediate access to a value.

The safer solution may be to load the value earlier and maintain an in-memory cache.

For example:

class SessionStore {
  private token: string | null = null;

  async initialise(): Promise<void> {
    this.token = await SecureStorage.getAccessToken();
  }

  getCachedToken(): string | null {
    return this.token;
  }
}
Enter fullscreen mode Exit fullscreen mode

The native storage operation remains asynchronous:

await sessionStore.initialise();
Enter fullscreen mode Exit fullscreen mode

Later reads are synchronous because they access application memory:

const token = sessionStore.getCachedToken();
Enter fullscreen mode Exit fullscreen mode

This creates a meaningful distinction:

  • loading from storage is asynchronous
  • reading an already-loaded value is synchronous

That is much safer than hiding storage access behind a synchronous API.

However, caching introduces its own responsibilities:

  • cache invalidation
  • logout handling
  • token refresh
  • memory lifecycle
  • multi-process consistency
  • protection of sensitive values

The goal is not to make everything synchronous.

The goal is to place expensive work outside latency-sensitive paths.


Measure the whole native method

A common performance mistake is to measure only JavaScript execution.

For example:

const start = performance.now();

const token = SecureStorage.getAccessToken();

console.log(`Completed in ${performance.now() - start} ms`);
Enter fullscreen mode Exit fullscreen mode

This can reveal blocking time from the JavaScript side.

But deeper investigation may require native instrumentation.

On Android, developers can measure sections using native timing tools or platform profilers.

On iOS, Instruments can help identify time spent in storage, cryptography, object conversion and thread blocking.

The useful questions are:

  1. How long does the method take on average?
  2. What is the worst observed latency?
  3. Does the duration change when the value is not cached?
  4. Does the method perform disk access?
  5. Does it allocate or convert large objects?
  6. Is it called during rendering or navigation?
  7. How many times is it called during one user action?
  8. How does it behave on lower-end devices?

Average timing alone is not enough.

A method that normally completes quickly but occasionally blocks for 40 milliseconds can still create visible UI problems.


Watch for repeated synchronous calls

The cost of one method call may appear acceptable.

The call frequency may be the actual problem.

Consider:

items.map(item => NativeFormatter.format(item));
Enter fullscreen mode Exit fullscreen mode

If there are 500 items, the application performs 500 synchronous native calls.

Even if each call takes only a fraction of a millisecond, the combined cost can become significant.

A stronger native API might process the entire collection:

const formattedItems = await NativeFormatter.formatBatch(items);
Enter fullscreen mode Exit fullscreen mode

This can reduce:

  • repeated boundary crossings
  • repeated allocations
  • thread coordination
  • per-call conversion overhead

Batching is especially useful when working with:

  • database rows
  • image metadata
  • sensor records
  • BLE data
  • file lists
  • cryptographic operations
  • large arrays produced by native SDKs

The correct API shape often matters as much as the implementation.


Synchronous methods create architectural coupling

There is another risk beyond immediate performance.

A synchronous API can become slow later.

Version one may return an in-memory value:

getUserRegion(): string;
Enter fullscreen mode Exit fullscreen mode

A future requirement may change the implementation:

  • read the region from encrypted storage
  • migrate an old value
  • consult remote configuration
  • query a native SDK
  • support multiple user profiles

The JavaScript interface remains synchronous, but the native responsibility has become heavier.

Now the team faces a difficult choice:

  • keep blocking JavaScript
  • introduce hidden caching
  • change the public API
  • maintain both synchronous and asynchronous versions

For values likely to require I/O in the future, starting with an asynchronous contract is often safer.

API design should consider how the operation may evolve, not only how it works today.


A practical decision rule

Before exposing a synchronous TurboModule method, ask:

Can this method always complete quickly without storage, networking, cryptography, large conversion or unpredictable system work?

If the answer is uncertain, use an asynchronous API.

A useful classification is:

Suitable for synchronous execution

  • constants
  • cached primitives
  • tiny calculations
  • in-memory flags
  • deterministic capability checks

Better suited for asynchronous execution

  • file access
  • secure storage
  • databases
  • networking
  • encryption and decryption
  • compression
  • image processing
  • large data conversion
  • hardware interaction
  • operating-system services

The method’s name is not enough.

Review the complete native execution path.


Code-review checklist

When reviewing a synchronous TurboModule method, check:

  • Does it perform disk or secure-storage access?
  • Does it call another SDK or system service?
  • Does it perform cryptography?
  • Can it return a large object or array?
  • Does it parse or serialise data?
  • Can its latency change based on device state?
  • Is it called during rendering?
  • Is it called repeatedly inside a loop?
  • Is the result already available in memory?
  • Could the method become more expensive later?

One concerning answer may be enough to reconsider the synchronous design.


Final takeaway

TurboModules can improve React Native’s native integration model.

They make direct and synchronous interactions possible where appropriate.

But capability should not become default behaviour.

The safest rule is:

Use synchronous native methods only for tiny, predictable and memory-resident operations.

Keep storage, cryptography, networking, large data processing and unpredictable system work asynchronous.

TurboModules reduce communication overhead.

They do not remove the cost of native execution.

Where have you seen a synchronous native method create unexpected UI latency in a React Native application?


Top comments (0)