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.
I'll try to keep it simple.
Today React-Native works using 3 main threads
The JavaScript Thread. This is the place where the entire JavaScript code is placed and compiled
The Native Thread. This is the place where the native code is executed.
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
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
🕵️ 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);
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
]
]
}
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
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>
);
};
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>
);
};
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();
});
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();
}
};
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
Top comments (1)
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.