skip to content

In a React Native 0.87 Turbo Module, on which thread do synchronous-return, void and Promise methods run, and why does that matter in production?

level: seniorimportance: should knowfreq 35%

answer

  1. the return type picks the thread
  2. value return: JS thread, blocking
  3. void and Promise: native modules queue
  4. neither is the main UI thread
  5. sync throws are catchable; async throws are not

basics

~20 s

A method that returns a value runs synchronously on the JS thread and blocks it; void and Promise methods run later on a background native-modules queue. None of them runs on the main thread, so UI work needs explicit dispatch.

solid answer

~50 s

The spec's return type decides. A method returning a string, number, boolean, object or array is **synchronous**: it executes on the JS thread and JavaScript waits, so 50 ms of native work is 50 ms of stalled JavaScript. `void` and `Promise` methods are **asynchronous**: the call returns immediately and the body runs on a native-modules queue (by default one shared background thread on Android and the module's method queue, shared by default, on iOS). Neither is the main thread, so UIKit or Android view calls must be posted to it. Because that queue is shared, a blocking wait in one module delays other modules' calls, so slow work belongs on the module's own worker. Errors differ too: a sync method's exception becomes a catchable JS error, while an exception in a `void` method is re-raised natively.

code

objective-cpp · 18 lines
objective-cpp
- (NSNumber *)lastBpm
{
  return @(self.cachedBpm);
}

- (void)readBatteryLevel:(RCTPromiseResolveBlock)resolve
                  reject:(RCTPromiseRejectBlock)reject
{
  dispatch_async(self.bleQueue, ^{
    NSError *error = nil;
    NSNumber *level = [self.strap readBatteryLevel:&error];
    if (error != nil) {
      reject(@"E_BATTERY_READ", error.localizedDescription, error);
      return;
    }
    resolve(level);
  });
}

go deeper

for a junior

Remember that returning a value makes a native method synchronous on the JS thread, while void and Promise methods run in the background.

for a middle

Explain the three kinds, which queue each uses on iOS and Android, and why none of them is the main thread.

for a senior

Show how you find a sync method freezing JavaScript, move blocking work off the shared queue, dispatch UI work correctly and turn native failures into rejections instead of crashes.

for a principal

Set rules for a team's native modules: when a synchronous API is ever acceptable, how blocking work is isolated, and how error codes are standardised across platforms.

## The return type picks the thread In the **New Architecture** (the only architecture since React Native 0.82), JavaScript calls a Turbo Module method directly through JSI, the C++ interface to the JavaScript engine. What happens next depends only on the method's declared return type: | Spec return type | Kind | Where the native body runs | What JavaScript sees | |---|---|---|---| | `string`, `number`, `boolean`, object, array | synchronous | on the **JS thread**, before the call returns | the value, immediately | | `void` | asynchronous | on a **native-modules queue** | `undefined`, immediately | | `Promise<T>` | asynchronous | on a **native-modules queue** | a pending Promise, immediately | On iOS the asynchronous work goes to the module's method queue; unless the module supplies one, that is a queue shared by modules. On Android it goes to the shared native-modules queue thread. The iOS `methodQueue` override that older modules used is deprecated: dispatch explicitly inside the method instead. ## Why synchronous methods are dangerous The JS thread runs your React components, event handlers and effects. A synchronous method blocks all of that for as long as the native body takes. For a cycling app talking to a Bluetooth heart-rate strap: - `getLastBpm(): number` returning a cached value is fine; it costs microseconds. - `readBatteryLevel(): number` that waits for a Bluetooth round trip would freeze touch handling and JS-driven animations for the whole wait. So make anything that touches the radio, disk or network a `Promise` method, and keep sync returns for data already in memory. ## Why asynchronous methods still need care 1. **Not the main thread.** The native-modules queue is a background queue. Calling UIKit or touching Android views from it is a bug; post that work to the main thread yourself (for example with `dispatch_async(dispatch_get_main_queue(), ...)` on iOS). 2. **A shared queue.** Because modules share the default queue, a method that blocks it (a synchronous wait for a scan to finish) delays unrelated modules' calls queued behind it. Long work should move to the module's own serial queue or worker thread, then call `resolve` from there. 3. **Settling from any thread is fine.** `resolve`, `reject`, callbacks and event emits can be called from any thread; React Native schedules the JavaScript side onto the JS thread. 4. **Ordering.** The default queues are serial, so asynchronous calls run in call order, but JavaScript has already moved on, so code must not assume the native side has finished when the call returns. ## Choosing a queue for the strap module A Bluetooth module has ordering needs of its own: a connect must finish before notifications are enabled. Giving the module one **private serial queue** (on iOS) or a single worker thread (on Android) for all radio work keeps those steps in order, keeps the shared native-modules queue free, and gives one place to settle Promises and emit readings from. The JavaScript side never sees which queue did the work; it only sees Promises settling and events arriving on the JS thread. ## Exceptions depend on the kind - **Synchronous methods:** a native exception is converted into a JavaScript error at the call site on both platforms, so `try/catch` works. - **Void methods:** the exception is re-thrown on the native queue, not delivered to JavaScript; in a release build that typically crashes the app. - **Promise methods:** on Android an exception thrown inside the method body is converted into a rejection; on iOS an `NSException` from an asynchronous method is re-raised. Portable code catches its own errors and calls `reject(code, message)` explicitly. ## How this differs from the legacy bridge Under the removed legacy architecture, every exported method was asynchronous by default; a synchronous method needed `RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD` on iOS or `@ReactMethod(isBlockingSynchronousMethod = true)` on Android, and the docs discouraged it. With Turbo Modules, declaring a return value is enough to make a method synchronous, which is why a careless spec can now freeze JavaScript. ## A production checklist - Audit every spec method with a non-`void`, non-`Promise` return: is it guaranteed cheap? - Move blocking I/O onto a dedicated worker and settle the Promise from there. - Dispatch UI work to the main thread explicitly. - Wrap native bodies so failures become rejections with stable codes, never crashes.

  • A module method needs to present a native view controller. What must it do?
    Declare it `void` or `Promise`, then dispatch the presentation to the main queue inside the method, for example with `dispatch_async(dispatch_get_main_queue(), ...)`. The method body runs on a background native-modules queue, and overriding `methodQueue` to force the main queue is deprecated.
  • Why can one slow module make another module's calls late?
    By default asynchronous calls from many modules share one native-modules queue (a single thread on Android, a shared queue on iOS). A method that blocks that queue holds up everything behind it. Moving blocking work to the module's own queue or thread keeps the shared queue free.

The JS thread is a single cashier: a synchronous call makes the cashier walk to the stockroom while the queue waits, whereas a Promise call hands a note to the stockroom runner and keeps serving customers until the runner shouts back.

saying these in an interview costs you the question

  • All Turbo Module methods run asynchronously, whatever they return
  • Promise methods execute on the main UI thread, so UIKit calls are safe
  • A sync method is harmless because JSI calls have no overhead
  • Each asynchronous call gets its own freshly created thread
  • An exception in a void method becomes a catchable JavaScript error
  • resolve must be called on the same thread the method started on