Every React Native app has been paying a small, practically invisible cost on every render. Multiply it by the number of <Text>s and <View>s on screen, multiply that by the frame rate you’d like to hit, and that median Android device you’re targeting starts dropping those frames before it even gets to do any actual work.
I spent the better part of last year writing a Babel plugin to fix that. While the plugin is interesting, it’s not the point of this post. The cost itself is. We’ll look at what it is, why your profiler can’t see it, and why it’s still in React Native’s source code, despite its core maintainers having effectively stated it’s problematic.
What <Text>Hello</Text> does under the hood
If you look at the code of react-native@0.83.0 and find Libraries/Text/Text.js, you’ll immediately notice it’s 848 lines. Specifically, 848 lines of JavaScript. Ask any junior, mid-level or even most senior React Native developers, and they’ll probably tell you this component is a native primitive of the platform’s UI toolkit. If you look at this JavaScript code, you’ll realize it isn’t.
A lot of those lines are gated behind feature flags or __DEV__ branches, so the steady-state cost of rendering <Text>Hello</Text> is smaller than this line count suggests. Yet, it’s not zero. Let’s look at what this code actually does, each time you render some text.
The wrapper destructures around thirty named props out of the props object, making thirty property reads. Then it transforms a bunch of accessibility properties, such as translating aria-label to accessibilityLabel, merging aria-busy/-checked/-disabled/-expanded/-selected into an accessibilityState object (allocating that object whenever any of the five is set), reconciling disabled, mapping aria-hidden to accessibilityElementsHidden, and renaming id to nativeID. Then it checks whether the Text is pressable, clamps numberOfLines to non-negative, calls flattenStyle on the style array (walking and merging it on every render even when the style is a frozen StyleSheet.create reference), normalizes fontWeight, looks up some other properties in maps, … you get the idea. Last but not least, it decides whether to wrap the whole thing in a <TextAncestorContext value={true}> Provider, telling descendants they’re inside a Text. This provider, when needed, is itself an extra fiber that React has to mount and commit. It also subscribes to TextAncestorContext, adding this Text to the context’s dependency list, so any change to whether we are “inside another Text” re-renders every Text in the subtree.
For a <Text> with no fancy props, most of the cheap checks early-return after a single comparison. What does not early-return, regardless of what props you pass: a function-component invocation, the destructuring itself, the context subscription, and that extra fiber in the React tree.
A modern Pixel running Hermes spends roughly twenty-five to forty microseconds on a “trivial” Text. A trivial View (another wrapper component that has similar issues) costs around half that. These numbers are order-of-magnitude and shift with prop shape, device thermals and bundle warmth. But they’re what you see in a CPU profile on a phone that costs $700.
The math that should bother you
Chances are, you’re saying “Twenty-five microseconds is nothing.”
A modest list row (name, subtitle, two badges, a timestamp, a price tag) is six or seven Texts and three or four Views. A real app, for example a banking transactions list, is closer to ten Texts per row. A social feed item with quote-counts, reaction counts, author handle, timestamp, badge, body, and a footer may run fifteen. Scroll momentum keeps roughly twenty visible rows in the active commit window at any moment (if your list is properly optimized, that is!). That’s two to three hundred Texts and around a hundred Views in flight per commit during scroll. At twenty-five microseconds a Text and fifteen a View, you’ve suddenly spent six to ten milliseconds of your frame budget on JavaScript that did not need to run at all. When you’re targeting 60 FPS, you have a frame budget of around ~16.7 ms, so you already spent 35-60% of that, without doing any helpful work. Oops!
You can of course do this math even more aggressively. A benchmark in the react-native-boost repository renders 10,000 Texts and 10,000 Views in a single commit. Obviously, that’s very artificial, but it’s a useful upper bound. With the wrappers, the commit takes nearly a second on an iPhone 16 Pro. Without them, it takes about half that. The cost is linear in the number of components.
Why your profiler doesn’t tell you this
Hamel Husain has a great post titled Fuck You, Show Me The Prompt. It’s about LLM frameworks, but one of its principles ports well here. Most of what’s expensive about your software is hidden by the abstractions you built to make it less expensive to write. You cannot see the cost until you look behind the curtain.
Open a profiler for a real screen. You will see fifty-three thousand calls to a function named Text, each one taking thirty microseconds, none of them individually slow. You may think “well, that’s just how long it takes to render a text”. Yes, but it shouldn’t need to be.
So far, the discourse about React Native performance has been about the things that show on a flamegraph. Bridge contention, JSON serialization, Yoga, re-renders. We migrated FlatList to FlashList and LegendList. We adopted Hermes. We rewrote our state management. We turned on the New Architecture (and watched our crash rate spike for a quarter while we sorted it out).
Don’t get me wrong: These were all the right fights and arguably much more important than what this post discusses. But they were also the fights with a single, clearly attributable bottleneck that has a more performant solution. The tax of these JS wrappers doesn’t have a one-size-fits-all solution. Most of their work is genuinely needed sometimes. But the runtime can’t know if it’s “sometime” without calculating if it is.
This has been known for a while
Of course, none of this is a novel discovery. The people who wrote React Native know about this, and have publicly committed to removing it.
Look at Libraries/Components/View/ViewNativeComponent.js, lines 42 and 43:
// Additional note: Our long term plan is to reduce the overhead of the <Text>
// and <View> wrappers so that we no longer have any reason to export these APIs.
The “APIs” being referred to are unstable_NativeText and unstable_NativeView, two components exported from react-native whose entire purpose is to let you skip the expensive JavaScript-based wrappers. They are, at runtime, the string tokens 'RCTText' and 'RCTView'. React’s reconciler treats them as host components, so when it sees one, it skips the JS function invocation entirely and goes straight to the native side.
The unstable_ prefix is doing the work here, of course. “We’re exposing this, but only because some library is going to need it, and don’t expect this to be stable for app code, or supported at all.”
They’re already actively working on this overhead reduction. RN 0.83 ships a feature flag called reduceDefaultPropsInText that strips several redundant default-prop checks from the wrapper. It’s an improvement, yet the serious work is structural, and will take a long time to roll out across the ecosystem.
”But the wrappers are doing real work”
As previously discussed, those wrappers exist for a reason. They handle genuine edge cases that would otherwise break your users’ experience if they weren’t.
Yet, this is a non-sequitur.
Almost everything the wrapper does is computable at build time, if you know what the call site looks like and what work is genuinely needed beforehand. If your <Text> has no onPress, you don’t need pressability. If it has no aria-* props, you don’t need to translate them. If its children are a string literal, you don’t need an ancestor context to disambiguate, and so on.
Most Texts in a real app are simple. They render a string. They have a style. That’s it. The runtime wrapper is doing work for a very tiny minority of Texts, and exactly the same work for the large majority. There is no point at which the function body can say “ah, this one’s simple, no need to do all that.”
However, a compiler can tell the difference. That’s its entire job. It looks at the call site, sees what’s actually written, and can do only the work that’s necessary.
react-native-boost is a Babel plugin that does this. For each <Text> and <View> in your source, it decides if the wrapper is needed at all, or if it can be removed (or if the partial work needed from the wrapper can be done at build-time).
A plain Text is rewritten to its native host component and the wrapper vanishes entirely. A Text that needs some of the wrapper’s work still becomes the native host, but with small, targeted runtime helpers. The fiber, the destructure, and the context subscription are gone in either case.
The View side has a little more engineering baked into it, because a View might be nested inside a Text, which would break with the native host. If it has Text descendants of its own, they depend on the wrapper flipping TextAncestorContext, which we discussed previously, back to false. Delete the wrapper without knowing about the ancestor and the inner Texts mis-layout. So the plugin walks the JSX tree at compile time, classifying each View’s ancestor chain.
The bias is asymmetric on purpose: a missed optimization wastes performance, but a false optimization breaks layout. Every bailout chooses the former rather than risk the latter. For the call sites the plugin can’t statically prove, the wrapper still ships and still runs. For everything else, the cost is paid once at build time, and your users don’t pay it during runtime. The full safety story is in the docs.
Conclusion
Most of the React Native performance discourse has been about paths. Use the New Architecture. Use FlashList. Use the Hermes path. Each one buys you a real, measurable win on the workloads it was designed for.
But the small constant, nearly invisible factors underneath are wins, too. You can adopt every shiny optimization React Native ships this decade and still pay the cost of such wrappers on every screen. It’s leaky, it’s distributed, and it politely refuses to visibly show up on your profiler.
I think we’ll see a lot more build-time optimizations like this, either from React Native core or from the community. The runtime is shared infrastructure. It has to be general; it has to handle every prop combination that’s allowed; it can’t say “you’re not using this, so I’ll skip it.” A compiler can. The React Compiler is the obvious example with the heaviest punch. We’ll likely see a lot more of them in the future.