DEV Community

Cover image for React Native - when JS is too busy
Matteo Boschi
Matteo Boschi

Posted on • Updated on

React Native - when JS is too busy

Let's see together how a few lines of code could make your app unresponsive. You should always keep in mind that, even if you are writing JS code, your are still executing your code in a device with limited resources.

5myqp4.jpg

I'll try to keep it simple.

Today React-Native works using 3 main threads

image.png
source

  1. The JavaScript Thread. This is the place where the entire JavaScript code is placed and compiled

  2. The Native Thread. This is the place where the native code is executed.

  3. The Shadow Thread. It is the place where the layout of your application is calculated

So it's pretty clear that the native side and the JS side have to communicate. This is done using the bridge: JS invokes native methods and receives back - through the bridge - method results and events coming from the user interaction

bridge.png

let's do an example: the user presses a button.

  • native code handles the onPress event
    • pack the payload to send over the bridge
    • send the payload
  • JS code
    • unpack the received payload
    • execute the bound code

event.png

🕵️ to spy on the bridge, include this code somewhere

import MessageQueue from "react-native/Libraries/BatchedBridge/MessageQueue";
const spyFunction = (spyData: SpyData) => {
    console.log(spyData);
};
MessageQueue.spy(spyFunction);
Enter fullscreen mode Exit fullscreen mode

what does the bridge data look like?
this is a single event sent from native to JS

{
  "type": 0,
  "module": "RCTEventEmitter",
  "method": "receiveTouches",
  "args": [
    "topTouchStart",
    [
      {
        "target": 363,
        "pageY": 552.3333282470703,
        "locationX": 11.666666030883789,
        "locationY": 16.666662216186523,
        "identifier": 1,
        "pageX": 198,
        "timestamp": 12629700.739797002
      }
    ],
    [
      0
    ]
  ]
}
Enter fullscreen mode Exit fullscreen mode

Our app is going to be non responsive 🥶

from The Ultimate Guide to React Native Optimization

the number of JavaScript calls that arrive over to the bridge is not deterministic and can vary over time, depending on the number of interactions that you do within your application. Additionally, each call takes time, as the JavaScript arguments need to be stringified into JSON, which is the established format that can be understood by these two realms.
For example, when your bridge is busy processing the data, another call will have to block and wait. If that interaction was related to gestures and animations, it is very likely that you have a dropped frame – the certain operation wasn’t performed causing jitters in the UI.

💥 [...] when your bridge is busy processing the data 👈
this is the point we're interested in. JS runtime has to handle all incoming messages from the bridge and also it has to run the JS app code.

So what if we write such a bad code that our JS runtime will be stuck busy for a long time?
can it also handle the events coming from the native side?
no! it can't

JavaScript single-threaded model

JavaScript is a single-threaded programming language. In other words, JavaScript can do only one thing at a single point in time

nodejs-event-loop.png
It tries to do its best by executing all code at the best performance: the event loop checks if there is some code to execute (in the call stack) and execute it!

When you write a code that can finish in the future like the setTimeout() function or make a fetch request (executed in the native side), the event loop - since it has to wait - places these futures in the queue and it goes on by picking the next code to execute from the stack! That’s why you’re feeling like you’re executing code in parallel.

When the execution of a block of code is completed, the event loop checks on the queue if there are some future results or callbacks ready and it adds them to the execution stack

☠️ make the app unresponsive

You just need to pay attention to how you write your code and avoid anything that could block the thread, like synchronous network calls or infinite loops.

In the previous paragraphs we learnt that the JS runtime is only capable of executing one task at a time. The execution of the current task blocks the other ones that are waiting until it is finished. We learnt that a code that could finish in the future (network call or some methods executed in the native side) is placed in a "waiting" queue and checked every time the run time is "free": if the result is ready, then it will be executed.

We also learnt that the JS runtime has to handle all messages coming from the native side. So, starting from the beginning of this article

Can a piece of code freeze our app?

yes! Here’s why

If we write a very expensive block of code, we'll keep event loop busy for a while (depends how much time the task takes). In that timespan JS isn't able to process other events, like those coming from the native side.

Here’s an example where the user interacts with the UI and the interaction is normal and smooth.

const initialCounter = 1;
export const Counter = () => {
  const [counter, setCounter] = useState(initialCounter);
  return (
    <Content>
      <Button onPress={() => setCounter(c => c + 1)}>
        <Text>Increase counter</Text>
      </Button>
      <Text>{`counter: ${counter}`}</Text>
      <Button
        primary={true}
        bordered={true}
        onPress={heavyCode}
      >
        <Text>🔥🔥 run heavy code</Text>
      </Button>

      <Button
        primary={true}
        bordered={true}
        onPress={() => setCounter(initialCounter)}
      >
        <Text>reset</Text>
      </Button>
      <Slider minimumValue={0} maximumValue={10} style={{ width: 200 }} />
    </Content>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now let's introduce some code that blocks JS runtime so that any other tasks can't executed

const heavyCode = () => {
  let n = 100000000;
  while (n > 0) {
    n--;
  }
};

const initialCounter = 1;
export const Counter = () => {
  const [counter, setCounter] = useState(initialCounter);
  return (
    <Content contentContainerStyle={styles.contentContainerStyle}>
      <Button onPress={() => setCounter(c => c + 1)}>
        <Text>Increase counter</Text>
      </Button>
      <Text>{`counter: ${counter}`}</Text>

      <Button
        primary={true}
        bordered={true}
        onPress={heavyCode}
      >
        <Text>🔥🔥 run heavy code</Text>
      </Button>
      <Button
        primary={true}
        bordered={true}
        onPress={() => setCounter(initialCounter)}
      >
        <Text>reset</Text>
      </Button>
      <Slider minimumValue={0} maximumValue={10} style={{ width: 200 }} />
    </Content>
  );
};
Enter fullscreen mode Exit fullscreen mode

As you can see we pressed the button but the UI updates after 3-4 seconds 😱

This is because the JS runtime is busy executing our bad code. While it is busy the user presses the button multiple times, the Native side sends those events through the bridge, but they are queued and they cannot be evaluated by JS until it ends the execution of this blocking task.
This is the reason why you see the counter updates forming a row after a few seconds.

Can a Promise solve or reduce the problem? no.
Executing some blocking code inside of a Promise is the same as executing it and at the end of callback

const heavyCodeAsync = (): Promise<void> =>
  new Promise(resolve => {
    heavyCode();
    resolve();
  });
Enter fullscreen mode Exit fullscreen mode

TL;DR

If you run a blocking code in your JS/TS, your app will be stuck or laggy: the interaction inputs won't be processed at the expected time.

How can I avoid it?

  • 👍 don't write blocking code
  • 👍 if you can't avoid that code, process it in the native side and give back only the results
  • ☘️ take advantage of the next coming React Native architecture JSI + Fabric Rendering + Turbo Modules
  • 🔨 run your code at the right time, if it's possible InteractionManager.runAfterInteractions
  • 🤮 give your code some time to breathe. In this case runtime can schedule other tasks, like handling inputs from native
const breath = (): Promise<void> =>
  new Promise(resolve => {
    setTimeout(resolve, 20);
  });

const heavyCode = async () => {
  let n = 100000000;
  while (n > 0) {
    n--;
    await breath();
  }
};
Enter fullscreen mode Exit fullscreen mode

Which kind of scenario can cause this pitfall?

  • making heavy computation (i.e: ordering large arrays, crypto, strong math operation, image processing, etc..)
  • spawn a lot of Promise (a lot of "async" tasks could flood the JS runtime)
  • unnecessary re-renders
  • not optimized / bad code

I hope you enjoy this article 💪

here’s my environment where I ran the examples. The JS runtime is hermes on iOS

System:
    OS: macOS 11.4
    CPU: (12) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
    Memory: 219.67 MB / 32.00 GB
    Shell: 5.8 - /bin/zsh
  Binaries:
    Node: 12.13.0 - ~/.nodenv/versions/12.13.0/bin/node
    Yarn: 1.22.11 - /usr/local/bin/yarn
    npm: 6.12.0 - ~/.nodenv/versions/12.13.0/bin/npm
    Watchman: 2021.09.06.00 - /usr/local/bin/watchman
  Managers:
    CocoaPods: 1.10.1 - /usr/local/bin/pod
  SDKs:
    iOS SDK:
      Platforms: iOS 14.5, DriverKit 20.4, macOS 11.3, tvOS 14.5, watchOS 7.4
    Android SDK: Not Found
  IDEs:
    Android Studio: 4.2 AI-202.7660.26.42.7486908
    Xcode: 12.5.1/12E507 - /usr/bin/xcodebuild
  Languages:
    Python: 2.7.16 - /usr/bin/python
  npmPackages:
    @react-native-community/cli: Not Found
    react: 17.0.1 => 17.0.1 
    react-native: 0.64.2 => 0.64.2 
  npmGlobalPackages:
    *react-native*: Not Found
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Great article. I made a library to help with this when you have long running JS (like sorts etc, big JSON parse). It's available on MIT license here.