DEV Community

Cover image for React Native, from a blank machine to a shipped app · 1. What a React Native app actually is
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

React Native, from a blank machine to a shipped app · 1. What a React Native app actually is

React Native, from a blank machine to a shipped app · Chapter 1 of 12 · Mobile · new chapter every Monday morning

By the end of this chapter: Separate the JavaScript thread, the native threads and the renderer, so that every later chapter has somewhere to put itself.

The problem

Emma is a junior mobile engineer who has just finished the “Hello World” screen of her first React Native project. She wants to add a feature that parses a CSV file on the device and displays a progress bar while the work is being done. She writes a JavaScript for loop that iterates over the rows, updates a state variable each 100 rows, and expects the UI to stay responsive. Instead, the progress bar never moves, the app freezes for several seconds, and the iOS build logs a warning:

[ReactNative] JS thread blocked for 5000ms
Enter fullscreen mode Exit fullscreen mode

Her manager asks for a demo tomorrow, and the freeze means the deadline will be missed, the internal tester will report a “non‑responsive UI”, and the team will have to re‑estimate the story. Emma knows the code is running, but she cannot see why the UI thread is blocked or how to move the heavy work off the main thread. She has read the React Native docs, but they do not explain which thread does what, how the bridge works in practice, or how to verify the separation of concerns.

The goal of this chapter is to give Emma (and anyone in the same spot) a concrete mental model of the three execution contexts in a React Native app—JavaScript thread, native UI thread, and the renderer—and to show her how to confirm, with real commands and logs, that each piece of code runs where it should.


Before you start

Requirement Version / Tool Verification command
Node.js 18.17.0 (or later) node -v → v18.17.0
npm 9.8.1 (or later) npm -v → 9.8.1
React Native CLI 2.0.1 (global) npx react-native -v → react-native-cli: 2.0.1
React Native 0.76.0 (project template) will be created by npx react-native init
Xcode 15.2 (macOS) xcodebuild -version → Xcode 15.2
Android Studio Flamingo (2022.2.1) with SDK 33 `sdkmanager --list
JDK 11.0.22 {% raw %}java -version → openjdk version "11.0.22"
CocoaPods 1.12.0 pod --version → 1.12.0
Flipper (optional) 0.227.0 flipper --version → 0.227.0

Run the following command to confirm the environment is ready for a new RN project:

npx react-native info
Enter fullscreen mode Exit fullscreen mode

You should see a JSON‑like output listing the versions above, plus a line ✔️ Your environment looks good!. If any item is missing, install it before proceeding.


Where does my JavaScript actually run?

Question: When I call a function from a React component, which thread executes it?

React Native ships a separate JavaScript engine (Hermes by default on iOS/Android) that runs in its own OS thread, called the JS thread. All JavaScript code—including component lifecycle methods, useEffect callbacks, and Redux reducers—executes there. The thread is created by the native host (iOS RCTBridge or Android ReactInstanceManager) when the app starts.

Why it matters: If you perform a CPU‑intensive operation on the JS thread, the bridge cannot process messages to the UI thread, resulting in the UI freeze Emma observed.

How to confirm: Insert a console.log that prints the current thread ID via a native module (see the “Build it” section). The log will show something like JS Thread – ID 12. If you see the same ID for a function you thought was “native”, it is still running on the JS thread.


Which thread paints the screen?

Question: What renders the React component tree onto the device screen?

The UI thread (also called the main thread on iOS and the “UIManager” thread on Android) is responsible for all native view operations: creating UIViews, updating layout, handling touch events, and drawing frames at 60 fps. React Native’s Fabric renderer (enabled by default in RN 0.76) receives a batch of UI commands from the JS thread over the bridge and applies them on the UI thread.

Why it matters: The UI thread must stay free to process the compositor’s VSync callbacks. Anything that blocks it (e.g., a long‑running native method called synchronously) will cause dropped frames and jank.

How to confirm: In Xcode’s Debug navigator, select the Main Thread and watch the call stack while a state update occurs. You should see RCTViewManager methods executing there. On Android, open Android Studio > Profiler > CPU and filter for UI Thread.


What is the renderer and how does it talk to the JS thread?

Question: How do the three threads exchange data without stepping on each other?

React Native uses a bridge (a message queue) to serialize calls between the JS thread and the native side. With Fabric, the bridge is batched and asynchronous: the JS thread pushes a JSON‑like “shadow node” description into a shared memory buffer; the native side reads the buffer, creates or updates native views, and then the UI thread renders them.

The renderer (Fabric) lives on the native side but is not the UI thread; it runs on a layout thread (sometimes called the “React thread”). Its job is to compute layout using Yoga, diff the tree, and send the minimal set of UI commands to the UI thread.

Why it matters: Understanding that the renderer is a distinct thread helps you decide where to place heavy work. Layout calculations are cheap; anything that blocks the UI thread is fatal; the JS thread can be off‑loaded to a separate JSI module if needed.

How to confirm: Enable the “Fabric Debug” flag in AppDelegate.m (iOS) or MainApplication.java (Android) and watch the log line Fabric: UI Manager queue processing batch. The timestamp will show that the batch is processed after the JS thread logs its own messages.


Build it

The following steps create a minimal app that demonstrates the three threads, logs their IDs, and lets you see the effect of a heavy JS loop.

1. Initialise a fresh project

npx react-native init ThreadDemo --version 0.76.0
cd ThreadDemo
Enter fullscreen mode Exit fullscreen mode

Why: Guarantees a clean baseline with Fabric enabled (default in 0.76).

Confirm: Run npx react-native run-ios (or run-android). The app should launch with a blue screen displaying “Welcome to React Native”.

2. Add a native module that reports its thread name

iOS (Swift)

Create ThreadInfoModule.swift in ios/ThreadDemo/:

import Foundation
import React

@objc(ThreadInfoModule)
class ThreadInfoModule: NSObject {

  @objc
  func getThreadName(_ resolve: RCTPromiseResolveBlock,
                     rejecter reject: RCTPromiseRejectBlock) {
    let name = Thread.isMainThread ? "UI Thread" :
               (Thread.current.name ?? "JS Thread")
    resolve(name)
  }

  @objc
  static func requiresMainQueueSetup() -> Bool {
    // false => can be called from any thread
    return false
  }
}
Enter fullscreen mode Exit fullscreen mode

Create a bridging header ThreadDemo-Bridging-Header.h (if not already present) and add:

#import <React/RCTBridgeModule.h>
Enter fullscreen mode Exit fullscreen mode

Edit ios/ThreadDemo/AppDelegate.m to register the module:

#import <React/RCTBridge.h>
#import <React/RCTBridgeModule.h>
#import "ThreadInfoModule.h"

@implementation AppDelegate

- (NSArray<id<RCTBridgeModule>> *)extraModulesForBridge:(RCTBridge *)bridge {
  return @[[ThreadInfoModule new]];
}
@end
Enter fullscreen mode Exit fullscreen mode

Run cd ios && pod install && cd .. to update pods.

Android (Java)

Create ThreadInfoModule.java in android/app/src/main/java/com/threaddemo/:

package com.threaddemo;

import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

public class ThreadInfoModule extends ReactContextBaseJavaModule {

  public ThreadInfoModule(ReactApplicationContext reactContext) {
    super(reactContext);
  }

  @Override
  public String getName() {
    return "ThreadInfoModule";
  }

  @ReactMethod
  public void getThreadName(Promise promise) {
    String name = Thread.currentThread() == Looper.getMainLooper().getThread()
        ? "UI Thread" : "JS Thread";
    promise.resolve(name);
  }
}
Enter fullscreen mode Exit fullscreen mode

Register it in MainApplication.java:

import com.threaddemo.ThreadInfoModule; // add import

@Override
protected List<ReactPackage> getPackages() {
  @SuppressWarnings("UnnecessaryLocalVariable")
  List<ReactPackage> packages = new PackageList(this).getPackages();
  packages.add(new ThreadInfoPackage()); // you need a package class that returns the module
  return packages;
}
Enter fullscreen mode Exit fullscreen mode

Create ThreadInfoPackage.java:

package com.threaddemo;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class ThreadInfoPackage implements ReactPackage {
  @Override
  public List<NativeModule> createNativeModules(
      com.facebook.react.bridge.ReactApplicationContext reactContext) {
    List<NativeModule> modules = new ArrayList<>();
    modules.add(new ThreadInfoModule(reactContext));
    return modules;
  }

  @Override
  public List<ViewManager> createViewManagers(
      com.facebook.react.bridge.ReactApplicationContext reactContext) {
    return Collections.emptyList();
  }
}
Enter fullscreen mode Exit fullscreen mode

Re‑build the Android project:

npx react-native run-android
Enter fullscreen mode Exit fullscreen mode

Confirm: The native module should be available; no compile errors.

3. Call the native module from JavaScript

Edit App.js:

import React, {useEffect, useState} from 'react';
import {Button, SafeAreaView, Text, View} from 'react-native';
import {NativeModules} from 'react-native';

const {ThreadInfoModule} = NativeModules;

export default function App() {
  const [jsThread, setJsThread] = useState('unknown');
  const [nativeThread, setNativeThread] = useState('unknown');
  const [counter, setCounter] = useState(0);

  // Log the JS thread ID (Hermes prints a thread ID in console)
  useEffect(() => {
    console.log('JS thread start');
  }, []);

  const fetchThreadNames = async () => {
    const native = await ThreadInfoModule.getThreadName();
    setNativeThread(native);
    setJsThread('JS Thread – see console for ID');
  };

  const heavyLoop = () => {
    // 10 million iterations – intentionally blocks JS thread
    let sum = 0;
    for (let i = 0; i < 10_000_000; i++) {
      sum += i;
    }
    setCounter(sum);
  };

  return (
    <SafeAreaView style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
      <Text>JS Thread: {jsThread}</Text>
      <Text>Native Thread: {nativeThread}</Text>
      <Button title="Get Thread Names" onPress={fetchThreadNames} />
      <View style={{marginTop: 20}}>
        <Button title="Run Heavy Loop" onPress={heavyLoop} />
        <Text>Result: {counter}</Text>
      </View>
    </SafeAreaView>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why: The button “Get Thread Names” calls the native module, which runs on a background native thread (not the UI thread). The “Run Heavy Loop” button blocks the JS thread, reproducing Emma’s symptom.

Confirm:

  • Press Get Thread Names → console shows JS thread start (Hermes prints JS thread start with a thread ID like JS thread start (tid=12)). The UI updates to Native Thread: UI Thread on iOS and Native Thread: UI Thread on Android, proving the native call ran on the UI thread because we returned Thread.isMainThread.

  • Press Run Heavy Loop → the UI freezes for ~2 seconds, the console logs a warning after 500 ms:

[ReactNative] JS thread blocked for 2000ms
Enter fullscreen mode Exit fullscreen mode
  • After the loop finishes, the result appears, confirming the JS thread was the bottleneck.

4. Verify the UI thread is still free during the native call

Open Flipper → React DevTools → select the app. In the “Performance” tab, start a recording, press Get Thread Names, stop recording. The timeline will show a short native call (≈1 ms) on the UI thread, with no jank.

Confirm: No red‑flag “UI thread blocked” appears.

5. Move the heavy work off the JS thread (optional)

Replace heavyLoop with a JSI‑based native module that performs the sum on a background thread. This step is beyond the scope of this chapter, but the pattern is: create a C++ JSI function, expose it via a native module, and call it from JS. The result will appear instantly while the UI remains responsive.

You now have a minimal, runnable app that demonstrates:

  • The JS thread runs all JavaScript.
  • The UI thread paints and handles touch.
  • The renderer (Fabric) processes layout on a separate native thread and pushes UI commands to the UI thread.

When this breaks

Symptom Likely cause Fix
Unable to find module 'ThreadInfoModule' (iOS) The Swift file was not added to the target or the bridging header is missing. Open ThreadDemo.xcodeproj, select the ThreadDemo target, ensure ThreadInfoModule.swift is listed under Compile Sources. Re‑run pod install.
java.lang.NoSuchMethodError: getThreadName (Android) The native module was not registered in MainApplication. Verify ThreadInfoPackage is added to getPackages(). Clean with ./gradlew clean and rebuild.
[ReactNative] JS thread blocked for … even after moving work to native The native method is still called synchronously on the UI thread. In the native module, return a Promise and perform the work on a background HandlerThread. Ensure requiresMainQueueSetup returns false.
UI freezes when pressing Get Thread Names The native method runs on the UI thread because requiresMainQueueSetup returned true. Change requiresMainQueueSetup to false (iOS) or use AsyncTask/Executor on Android.
No logs appear in Flipper’s Performance tab Fabric’s debug flag disabled. In AppDelegate.m, add RCTEnableFabric(true); before RCTAppSetupPrepareApp. Re‑run the app.

Each fix can be validated by repeating the step that originally produced the symptom and confirming the expected output (e.g., the module now resolves, the warning disappears).


What it costs

Separating concerns across three threads introduces overhead:

Cost Detail
Bridge latency Each message between JS and native incurs a copy (JSON → C++ → Objective‑C/Java). In a tight animation loop, this can add 1‑2 ms per frame, limiting max FPS.
Memory footprint Hermes (≈30 MB on iOS, 40 MB on Android) plus the Fabric renderer’s shadow node tree (≈10 MB for a medium screen). Devices with < 2 GB RAM may see higher memory pressure.
Complexity Native modules must respect thread boundaries; forgetting requiresMainQueueSetup = false can lead to UI jank. Debugging requires two toolchains (Chrome/Flipper for JS, Xcode/Android Studio for native).
Build time Adding native code triggers a full Xcode/Gradle rebuild, adding ~30 seconds per change on a typical laptop.

When the app is purely UI‑driven with minimal native interaction, you might accept the bridge overhead for faster iteration. For compute‑heavy workloads, consider moving logic to a JSI module or to a separate native thread to keep the JS thread free.


In the interview

Interviewer: “Can you walk me through the threading model of a React Native app and explain why a long‑running JavaScript loop can freeze the UI.”

Weak answer: “React Native has a JavaScript thread and a native thread, so the UI can still work while JS runs.”

Why weak: It glosses over the UI thread, the renderer, and the bridge; it does not mention the UI thread’s responsibility for drawing or the fact that the bridge is asynchronous but still blocks UI updates when the JS thread is busy.

Strong answer: “React Native runs JavaScript in a dedicated Hermes (or JSC) thread. The native side has three logical threads: the UI thread (main thread) that draws the view hierarchy, the Fabric renderer thread that computes layout and diffs the shadow tree, and the bridge thread that batches messages between JS and native. When the JS thread is blocked—say by a synchronous for loop—no new UI commands can be sent, so the UI thread has nothing to render and the frame drops, causing the freeze. The bridge itself does not block the UI thread, but the UI cannot update without fresh commands from JS. To avoid this, heavy work should be off‑loaded to a native module that runs on a background thread or to JSI, and any UI‑related native calls must stay on the UI thread to avoid deadlocks.”

What the interviewer probes further: “What trade‑offs does the bridge introduce, and how would you measure its impact in production?” A senior candidate should mention latency (ms per message), memory overhead of the shadow node tree, and profiling with Flipper’s “Performance” tab or Android Studio’s “CPU Profiler”.

Director‑level focus: “How does this model affect release planning and team organisation?” Expect discussion of separating JS‑only work from native module work, allocating dedicated native engineers for performance‑critical paths, and budgeting for extra QA on thread‑related bugs.

Follow‑up to separate experience from theory: “Tell me about a time you diagnosed a UI‑freeze caused by a JS‑thread blockage and how you resolved it.” Look for a concrete story that mentions the warning log, the use of console.time, moving code to a native module, and the measured improvement (e.g., FPS from 30 → 60).


Your tasks

  1. Log thread names – Extend the app to also log the renderer thread name. Create a native method that returns Thread.currentThread().getName() from the Fabric renderer (iOS: dispatch_get_current_queue()). Verify the output differs from both the UI and JS threads. Done when the UI shows three distinct labels.

  2. Detect a blocked JS thread – Add a setInterval that logs “tick” every 500 ms. Introduce a 3‑second synchronous while loop before the next tick. Observe the console warning and note the timestamp difference. Done when the warning appears and the interval resumes after the block.

  3. Off‑load work to a background native thread – Replace the heavy loop with a native module that performs the sum on a DispatchQueue.global() (iOS) or ExecutorService (Android) and resolves a Promise. Confirm the UI remains responsive while the calculation runs. Done when the progress bar (you may add a simple ActivityIndicator) does not freeze.

  4. Break the bridge – Modify the native module to call ThreadInfoModule.getThreadName synchronously on the UI thread (remove the Promise, return a string directly). Run the app and note the UI freeze when the button is pressed. Diagnose the problem using the “When this breaks” table and fix it. Done when the button no longer blocks the UI.

  5. Measure bridge latency – Using Flipper’s “Performance” tab, record a sequence of 100 state updates (e.g., a button that increments a counter). Export the timeline and note the average time between a JS setState call and the corresponding UI thread paint. Write the number (ms) in a comment at the top of App.js. Done when the comment contains a concrete figure (e.g., // Avg bridge latency: 1.8 ms).

Each task builds on the previous one, reinforcing the three‑thread model and exposing common failure modes.



Your tasks this week

Do the exercises above before the next chapter. Reading a tutorial and doing
one are different activities and only one of them changes what you can build.

Stuck on any of them? Say so — describe what you tried and what happened:
tell me where you got stuck. I read every one, and the questions
that come back more than twice get answered in the next chapter.

React Native, from a blank machine to a shipped app

Chapter 1 of 12. New chapter every Monday morning.
Next: A machine that builds: Node, JDK, Xcode and the Android SDK.

· The full syllabus and every chapter so far
· Subscribers also get the condensed notes for this chapter, the running
recap of everything the series has covered, and the extended guidance:
subscribe


Written by Amit Chakraborty — founding engineer and senior architect: React Native, AI and RAG systems, production architecture. Portfolio · LinkedIn · GitHub.

Need this built, reviewed or taught to your team? Get in touch or email amit@devamit.co.in. Available for senior and founding engineering roles, consulting and training, remote worldwide.

Top comments (0)