How Margelo Helped Discord Improve React Native's New Architecture Performance

margeloPosted On · 22 min read min read

Discord and Margelo had already solved many of the hardest challenges in migrating Discord's android app to React Native's New Architecture. But one obvious issue remained: animations felt terrible.

As one of Discord's engineering managers described it:

"The android app worked, but animations and transitions felt like a 2006 PowerPoint slideshow."

Since much of Discord's UI is powered by Reanimated v3, we knew the bottleneck had to be somewhere in how Reanimated interacted with the new architecture.

So what changed? In this post, we'll dive deep into Reanimated's internals, explore how Fabric's Shadow Tree works, and walk through the real performance issue we uncovered, plus the fix that brought animation smoothness back.

Left: New Arch unoptimized, Right: After optimizations

🚧

If you've never read or heard anything about React Native internals, the Shadow Tree, Shadow Nodes and related concepts, I highly recommend reading this 10-minute guide from React Native as a starting point: https://reactnative.dev/architecture/render-pipeline

How Reanimated Powers Animations

Reanimated currently provides three ways to run animations:

  • Layout Animations

  • CSS Transitions & Animations

  • shared value-driven animations using APIs like useAnimatedStyle

I don't have hard data for this, but at the time of writing I'd argue that in most React Native production apps the usage is "shared value-driven animations" > layout animations > CSS animations (as this was the order in which they were released, shared value-driven animations animations have existed since v2).

Discord relied heavily on shared value-driven animations, so that's where we started digging.

How Reanimated Sends Updates from JavaScript to C++

The Reanimated core that powers these animations is written in C++. So how are animation updates passed from your react components to C++?

Let's understand what's happening under the hood in the JavaScript realm through an example. Imagine you have this code:

JSX

function MyComp() {
  const opacity = useSharedValue(0)

  const styles = useAnimatedStyle(() => ({
    opacity: opacity.value
  }))

  useEffect(() => {
    opacity.value = withTiming(1, {
      duration: 500
    })
  }, [])
}

What Actually Drives a Reanimated Animation

Quiz time! Which of these three Reanimated APIs/functions is the one continuously driving the animation:

a) useSharedValue?
b) useAnimatedStyle?
c) withTiming?

Interestingly, none of them directly drive the animation. We're actually using a fourth, less obvious API here when we call opacity.value =

Looking at what useSharedValue returns, we can see that it internally creates something called a mutable :

packages/react-native-reanimated/src/hook/useSharedValue.ts

TypeScript • L20

View on GitHubCopy

const [mutable] = useState(() => makeMutable(initialValue));

makeMutable has two paths: either the mutable is created on the UI runtime, or it is created somewhere else, like the normal React Native JavaScript runtime.

In our case, makeMutable is called on the RN runtime, so Reanimated takes the second path and schedules the update onto the UI runtime.

🧠

This is an important detail: setting a shared value from JS is not really a synchronous write, even though opacity.value = ... looks like one. Under the hood, Reanimated is handing that work off to another runtime.

Once the update reaches the UI runtime, Reanimated calls valueSetter(...):

packages/react-native-reanimated/src/valueSetter.ts

TypeScript • L4-L25

View on GitHubCopy

export function valueSetter<Value>(

mutable: Mutable<Value>,

value: Value,

forceUpdate = false

): void {

'worklet';

...

if (

typeof value === 'function' ||

...

) {

const animation: AnimationObject<Value> =

typeof value === 'function'

? // TODO TYPESCRIPT fix this after fixing AnimationObject type

(value as () => AnimationObject<Value>)()

One interesting thing here is that the incoming value may actually be a function. In other words, withTiming(...) does not just return a number.

From animation definition function, Reanimated creates an animation object and advances it frame by frame.
The main loop is Reanimated's own requestAnimationFrame implementation running on the UI runtime (not the usual React Native JS requestAnimationFrame). Under the hood this is backed by Android's Choreographer and iOS's CADisplayLink:

packages/react-native-reanimated/src/valueSetter.ts

TypeScript • L4-L69

View on GitHubCopy

export function valueSetter<Value>(

mutable: Mutable<Value>,

value: Value,

...

const animation: AnimationObject<Value> =

...

const step = (timestamp: number) => {

if (animation.cancelled) {

animation.callback?.(false /* finished */);

return;

}

const finished = animation.onFrame(animation, timestamp);

animation.finished = true;

animation.timestamp = timestamp;

...

mutable._value = animation.current!;

if (finished) {

animation.callback?.(true /* finished */);

} else {

requestAnimationFrame(step);

}

};

mutable._animation = animation;

step(currentTimestamp);

So the loop is roughly:

  1. schedule the shared value update onto the UI runtime

  2. create the animation there

  3. advance it every frame using Reanimated's UI-thread requestAnimationFrame

  4. write the latest value back to the shared value

How Shared Values Reach Your Components

Once the shared value changes, Reanimated still needs to push that new value to the actual mounted view.

This is where useAnimatedStyle comes in. It creates a mapper, which is Reanimated's way of saying: "when one of these shared values changes, recompute the style".

But there's a second question too: which views should receive that style?

That part is handled by createAnimatedComponent. Components like Animated.View are really just React Native components wrapped by createAnimatedComponent:

packages/react-native-reanimated/src/component/View.ts

TypeScript • L2-L12

View on GitHubCopy

import { View } from 'react-native';

import { createAnimatedComponent } from '../createAnimatedComponent';

...

export const AnimatedView = createAnimatedComponent(View);

Inside that wrapper, Reanimated registers the mounted view with the animated style so it knows where future updates should go:

packages/react-native-reanimated/src/createAnimatedComponent/AnimatedComponent.tsx

TSX • L289-L295

View on GitHubCopy

style.viewDescriptors.add(

{

tag: viewTag,

shadowNodeWrapper,

},

style.styleUpdaterContainer

);

Internally, Reanimated stores those targets as "viewDescriptors", which include the view's tag and shadow node. That is what lets one animated style be applied to one or even multiple mounted views.

So there are really two pieces working together here:

  • the mapper answers: "when a shared value changes, what is the next style?"

  • the view descriptors answer: "which mounted native views should receive it?"

When the mapper runs, it computes the next animated props and eventually calls updateProps(...), which batches them before flushing to global._updateProps(...):

packages/react-native-reanimated/src/updateProps/updateProps.ts

TypeScript • L158-L160

View on GitHubCopy

flush(this: void) {

if (nativeOperations.length) {

global._updateProps!(nativeOperations);

That is the key handoff from the JavaScript side into Reanimated's native C++ implementation, as global._updateProps is a C++ function defined via JSI.

So the JavaScript-side pipeline looks something like this:

  • useSharedValue creates a mutable on the JS thread

  • assigning .value schedules work onto the UI runtime

  • withTiming provides the animation definition

  • Reanimated advances it with its own UI-thread requestAnimationFrame

  • useAnimatedStyle reacts to shared value changes via a mapper

  • createAnimatedComponent registers which mounted views should receive those updates

  • the resulting props are batched and sent to native through global._updateProps(...)

Up until this point this was mostly internal TypeScript implementation details, and I assume most of you are fairly comfortable writing TS code. Now let's move into the more interesting C++ internals and learn something new about how React Native New Architecture works internally.

I promise: no C++ experience needed to follow along! This is also the place where we will learn where and why the performance regressed on new arch :)

Darth Vader meme about C++ and React Native internals

Crossing Into Native: The C++ Runtime

If we search for the global._updateProps(...) function we saw in javascript we find it here in some C++ code:

packages/react-native-reanimated/Common/cpp/reanimated/RuntimeDecorators/UIRuntimeDecorator.cpp

C++ • L8-L20

View on GitHubCopy

void UIRuntimeDecorator::decorate(

jsi::Runtime &uiRuntime,

const ObtainPropFunction &obtainPropFunction,

const UpdatePropsFunction &updateProps,

...

const MaybeFlushUIUpdatesQueueFunction &maybeFlushUIUpdatesQueue) {

jsi_utils::installJsiFunction(uiRuntime, "_updateProps", updateProps);

Here it is installing some C++ functions into the global object of the JavaScript runtime, so that it can be accessed from JavaScript code. As you can see the function receives a uiRuntime (first argument). react-native-worklets creates a new JavaScript runtime on the UI thread for reanimated (to which we inject those functions).

These methods on global will only be available on the UI runtime, not on the JS runtime where the rest of your react-native app runs (aka. only in "worklet" functions).

The actual C++ updateProps function is defined in the ReanimatedModuleProxy which is one of the core pieces of Reanimated's C++ architecture.

packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp

C++ • L166-L174

View on GitHubCopy

auto updateProps = [weakThis = weak_from_this()](jsi::Runtime &rt, const jsi::Value &operations) {

auto strongThis = weakThis.lock();

if (!strongThis) {

return;

}

const auto timestamp = strongThis->getAnimationTimestamp_();

strongThis->animatedPropsRegistry_->update(rt, operations, timestamp);

};

If you're new to C++, the syntax may look unfamiliar, but just focus on the semantical sense here. We can see that we call animatedPropsRegistry_->update(rt, operations, timestamp);. This probably means that in C++ there is some kind of animatedPropsRegistry and that keeps track of the updates we have to perform!

What this also means is that even when our shared value updated on the UI thread, the animated style update is not applied immediately. The update is just put in this registry, that's all for now.

Our animated updates on new arch actually get applied by a function in ReanimatedModuleProxy called performOperations(). On old arch, there was a different code path. We will focus on this new code path since this is where the culprit lies to the performance issues we were seeing.

There are two main places from where this functions is invoked:

  • On the native side reanimated is using the requestAnimationFrame "native equivalent" which is subscribing to the Choreographer on android and CADisplayLink on iOS. Those are basically ways to allow you to run a function at the refresh rate of your phone's display (link to reanimated code). So on every new frame performOperations() will be called.

  • When a new native event happens. For example this could be a scroll event or a touch event for one of your gesture handlers. If you have a useAnimatedScrollHandler reanimated will intercept the scroll event that's happening on the UI thread, process your animated scroll handlers, and call performOperations() so that your gesture drives the animation synchronously!

performOperations() basically does two things:

  • it collects all updates to be performed. This includes updates from e.g. useAnimatedStyle but also from css animations & transitions.

packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp

C++ • L675-L704

View on GitHubCopy

void ReanimatedModuleProxy::performOperations() {

...

UpdatesBatch updatesBatch;

...

{

auto lock = animatedPropsRegistry_->lock();

// Flush all animated props updates

animatedPropsRegistry_->flushUpdates(updatesBatch);

}

  • It applies those updates synchronously! How does it do that? It's using one of react-native's Shadow Tree APIs that we are going to take a look at, it's called shadowTree.commit(...)

The code for applying the updates is here:

packages/react-native-reanimated/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp

C++ • L1200-L1218

View on GitHubCopy

const auto status = shadowTree.commit(

[&](RootShadowNode const &oldRootShadowNode) -> RootShadowNode::Unshared {

...

auto rootNode = cloneShadowTreeWithNewProps(oldRootShadowNode, propsMap);

...

return rootNode;

},

{/* .enableStateReconciliation = */

false,

/* .mountSynchronously = */ true});

  • On the first line we see the shadowTree.commit( function call. It receives a callback as parameter. That callback receives the oldRootShadowNode

  • Reanimated calls cloneShadowTreeWithNewProps(oldRootShadowNode, propsMap);. We will look at that in detail in a second, but the name is fairly self-explanatory. propsMap is the "shadowNode to: propsToUpdate" map that contains our animated updates!

  • It returns the new rootNode

So reanimated is basically applying our props updates on top of the previous/current shadow tree (where "shadow tree" is really the current root node).