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();
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);
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} />;
}
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
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();
Or checking a native constant:
const environment = AppConfiguration.getEnvironment();
Or performing a very small calculation:
const pixels = DisplayUtils.dpToPixels(16);
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();
Disk and secure-storage access may have unpredictable latency.
Cryptography
const decryptedPayload = await CryptoModule.decrypt(payload);
Cryptographic work can become expensive depending on the algorithm and input size.
Networking
const response = await NativeNetworkClient.fetchConfiguration();
Network operations are naturally unpredictable and should never block JavaScript synchronously.
Large data conversion
const records = await NativeDatabase.getRecords();
Converting thousands of native records into JavaScript objects can consume considerable time.
Image and file processing
const result = await ImageProcessor.compress(filePath);
Media processing is computationally expensive and should normally run away from latency-sensitive application work.
System services
const location = await LocationModule.getCurrentPosition();
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} />;
}
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} />;
}
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;
}
}
The native storage operation remains asynchronous:
await sessionStore.initialise();
Later reads are synchronous because they access application memory:
const token = sessionStore.getCachedToken();
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`);
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:
- How long does the method take on average?
- What is the worst observed latency?
- Does the duration change when the value is not cached?
- Does the method perform disk access?
- Does it allocate or convert large objects?
- Is it called during rendering or navigation?
- How many times is it called during one user action?
- 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));
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);
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;
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)