Published on 07/15/24
A few moons ago, I was working at a startup that had a problem. We had a pretty decent-looking mobile app that we had built in React Native, but it had been gradually getting slower and slower. We had mostly been ignoring this issue except in extremely bad performance circumstances that we bandaged over. But this issue came to a head when we had to upgrade to React Native 73+ (some Android and iOS bureaucracy had us on a hard deadline to upgrade). When we jumped React Native versions, we got the expected bugs from deprecated APIs being changed, but we also noticed a massive hit to our performance.
We had found some GitHub issues indicating similar performance concerns from members of the community, but all of the proposed solutions were either already integrated into v73 or didn't speed up our app.
We did what any good React Native developer would do any opened up
Flipper/React Devtools to investigate what was causing slow renders.
Unfortunately, our renders mostly seemed to be within the range we expected,
although some of our screens were making a few requests and re-rendering
with the new data every time any request came back. So I went and tried to
reduce the number of re-renders for a week, but even when I reduced one of
our screens to a super basic data fetch and display, there was still a ton
of lag on screen animations when navigating around. We did notice that there
was a lot of time taken in “passive effects”, which was a big clue for us,
but didn't seem to explain our issues given: 1) these effects are supposed
to be passive(!) and not interrupt re-renders 2) there didn't seem to be any
changes to the way
useEffect
calls were
handled from React Native 69 to React Native 73.
Eventually, I decided to give up on the React devtools approach and honed in on the difference between the performance on v69 and v73. We didn’t have any good metrics on app latency and all of our own feedback on latency had been around “feel” - the app just felt more sluggish across the board. I eventually narrowed down one interaction that definitely changed from v69 to v73 - when tapping into one of our heavier screens, the time for the swipe animation to start increased drastically on the upgraded version. At this point, I started investigating our navigation library ( React Navigation ). We figured out that something in the initial render was taking a lot longer in the new React Native version, and we could mitigate the lag symptoms by doing something like:
const MyHeavyComponent: React.FC = () => { useEffect(() => { ... }); return <HeavyStuff />; }; const MyComponent = () => { const [shouldRender, setShouldRender] = useState(false); InteractionManager.runAfterInteractions(()=> setShouldRender(true); }); if (!shouldRender) { return <Spinner />; } return <MyHeavyComponent />; }
NovemberFive has a great post on this technique here .
This made the app a lot faster in this particular interaction, but it still wasn't a particularly satisfying solution. For one, we still didn't know what had changed from version to version to make this experience worse. We also would have had to implement this bandaid for every screen in the app, and it didn't fix all interactions (e.g. if you navigated into a screen and then immediately back, the animations would hitch).
After a lot more bisecting, stripping down the code, and using the passive effects clues, I finally figured out a behavior that had changed from React Navigation in the upgrade: in React Native v69, effects that ran on the first render were run after the animation started (concurrently with the animation), whereas in RN v71+, effects that ran on the first render were run and completed before the swipe animation started.
This means that if you had a screen like the following:
const MyScreen = () => { useEffect(() => { doAMillionThingsSynchronously(); }, []); return <Content />; }
it would start the swipe animation significantly later on v73 than on v69.
Now we had to figure out what the cause of the large
useEffect
s was. Digging down the stack, it didn't seem like any significant work was going on, and React devtools wasn't giving us any hints on the large passive effects. A performance engineer from a different company had recommended using the
Hermes profiling instructions on Speedscope
at some point, so I booted it up in our production build on a real phone.
The big blob of JS callback hell in the middle that is taking 1.6 seconds is essentially a
JSON.stringify
equivalent that was getting called by
Sentry's Redux middleware
. This gave me a good idea of that the culprit was Sentry's state and action serialization for
Redux
, as we had had issues with extremely large payloads for Sentry's breadcrumbs in the past (with some of our endpoints returning megabytes of JSON).
Sentry does provide
stateTransformer
and
actionTransformer
options when initializing the Redux middleware, so by inserting some
stateTransformer: () => null
and kicking off a production build, I was able to confirm that the performance issues in
useEffects
were because of Sentry's serialization.
I also noticed we had
normalizeDepth: 5
in our Sentry initializer, which was contributing to our serialization issue.
Some of the mobile devs at the company liked Sentry having access to our actions for debugging purposes, so we ended up with a solution that looked something like this:
const OMITTED_ACTION_TYPES: Set= new Set([ 'user_info', ]); export const sentryReduxEnhancer = Sentry.createReduxEnhancer({ stateTransformer: () => null, actionTransformer: (action: Action) => { if ( OMITTED_ACTION_TYPES.contains(action.type) ) { return { ...action, payload: {} }; } else if (__DEV__ && JSON.stringify(action).length > 50000) { // eslint-disable-next-line no-console console.error( `[Internal] Redux Sentry Performance: action of type ${ action.type } too large, skipping breadcrumb. Please add ${action.type.toLowerCase()} to the OMITTED_ACTION_TYPES array.`, ); return null; } return action; }, });
…and voila, we had eliminated almost all of the performance hitches in our React Native app with only a few lines of code!
1. While React DevTools can be very valuable for identifying some component performance issues, it doesn't tell you the entire story of the JS stack; there can be other sources of latency in your app besides just the render body.
2.
Fix the slow render before you fix the re-render
. I spent way too long trying to cut down the number of re-renders in our app, thinking that the problem was not enough
useMemo
or
useCallback
. In reality, most of these re-renders were very easy for the processor to handle.
3. When trying to identify performance issues, narrow down the very specific aspect of your app that is lagging and break it down to the smallest reproducible example (just like with any debugging).