React Native Navigation Benchmarks: What Does Navigating to a Screen Cost?
/ 5 min read
Part of the series. Cold start data doesn’t include any taps, so I captured fresh traces: cold-launch each app, let Home settle, then input tap the shared button while Perfetto Systrace + Hermes record.
Press = startDispatchCycleLocked on system_server (the moment Android hands the tap to the app). Paint = first presented frame from SurfaceFlinger.
I ran this twice — once pushing a trivial Details screen (title + button, ~3 host nodes) and once pushing a 24-row FlatList rendered all at once (~290 nodes). The difference reveals how each library’s architecture holds up under real content.
Trivial screen: press → painted
| Library | Press → first frame | Press → fully painted | Frames | Janky |
|---|---|---|---|---|
| rn-navigation | 44 ms | 335 ms | 26 | 2 |
| navigation router | 51 ms | 330 ms | 25 | 15 |
| React Navigation | 62 ms | 522 ms | 43 | 7 |
| Expo Router | 61 ms | 520 ms | 43 | 8 |
All four feel instant — first content appears in 44–62 ms. The ~190 ms gap in fully painted is the transition animation length, not content cost. Expo Router and React Navigation are identical (same 43-frame native-stack slide) because Expo Router renders through React Navigation underneath.
The navigation router’s native Material transition ran 15/25 frames janky on this device despite the short total duration.
What JS is doing during the mount
The call stacks tell the architecture directly:
navigation router — 11.8 ms. Pushes a native Scene; JS just commits the content.
11.8ms [HostFunction] completeRoot — Fabric commit
rn-navigation — 19.5 ms. Native container, but “did appear” events cross back into JS.
beginWork → renderWithHooks → DetailsScreen [shared-ui]
triggerOnAllListenersByComponentId [react-native-navigation]
Expo Router — 47.8 ms. Fabric commit + React Context fan-out for URL/route state.
24.1ms [HostFunction] completeRoot
8.9ms propagateParentContextChanges
7.1ms latestCallback [expo-router]
React Navigation — 79.8 ms. Full reconciliation: new screen + navigator + synthetic-event churn.
16.4ms [HostFunction] completeRoot
10.7ms [HostFunction] appendChild
11.9ms beginEvent + updateCallback
6.7ms destructor + releasePooledEvent — event pool
For a trivial screen none of this is a bottleneck. Even React Navigation’s 80 ms of JS overlaps the input-to-frame pipeline — first paint stays under 62 ms for everyone.
Heavy screen: what changes with real content
| Library | JS mount | Press → first frame | Press → fully painted |
|---|---|---|---|
| rn-navigation | 20 → 87 ms | 44 → 61 ms ≈ | 335 → 319 ms ≈ |
| navigation router | 12 → 124 ms | 51 → 243 ms ↑ | 330 → 526 ms ↑ |
| React Navigation | 80 → 114 ms | 62 → 299 ms ↑ | 522 → 724 ms ↑ |
| Expo Router | 48 → 134 ms | 61 → 322 ms ↑ | 520 → 749 ms ↑ |
JS mount jumped for everyone — the leaf is the same across all four: [HostFunction] completeRoot on a ~290-node tree takes ~50–60 ms vs ~12–24 ms trivial. This is a Fabric cost, not a router cost. The navigation router’s “lean-JS” advantage from the trivial screen evaporates entirely.
rn-navigation barely moved on first frame (61 ms) while the others jumped to 243–322 ms. That’s two different strategies under load:
- rn-navigation starts the transition immediately, before content is ready. First frame is fast — but it then drops into a 221 ms “App Deadline Missed” frame while the 290-node tree commits. The screen appears, then visibly fills in.
- The others gate the transition on content. They hold the old screen until the new one renders, so first frame scales with mount cost. Once the animation starts, it’s smooth.
On settle time: rn-navigation finishes in ~320 ms (fast but with dropped frames), while the content-gated routers take 526–749 ms (slower to arrive, smoother in motion).
The choice is architectural. rn-navigation optimizes for time-to-first-pixel; the others optimize for a smooth animation. Neither is wrong — they fail differently. The practical fix for heavy screens is the same regardless of router: virtualize the list so the initial commit is a small batch, not 290 nodes at once.
Caveats
- n = 1 tap per library per screen. First-frame numbers carry ±~15 ms; jank counts are single samples.
- ”Heavy” = 24 rows committed synchronously (~290 host nodes). A virtualized list would be much cheaper and would largely eliminate the divergence.
- Transition durations are library defaults — all are configurable.
- Samsung Galaxy A16, Android 14, Hermes, New Architecture.
React Native Navigation Benchmarks